Compare commits

...

4 Commits

Author SHA1 Message Date
Claude 1d8ca453c9 feat(teleprompter): anchor the read position and step between events
The scroll position was a pixel offset into a document that changes while
it is being read. Growing an event above the reader left scrollTop where it
was, so the text under the reading line slid: measured going from "Lucas
Bennett" to "Lunch" on an unchanged offset of 900.

Hold the position as a place in the script instead — the block under the
reading line plus an offset into it — and re-resolve it to pixels whenever
the document is measured. This follows how professional prompters cue a
story plus an offset rather than a scroll offset, so a reorder carries the
reader with the block and an edit above them changes nothing. A deleted
block falls back to the end of the nearest surviving event before it.

Add event-to-event stepping on Shift with the vertical arrows, matching
Shift being the coarser step on the horizontal ones, and take the step from
where the scroll is headed so pressing again mid-ease moves on rather than
re-aiming at the same event.

Free scrolling and the existing nudge and page keys are unchanged, but they
now hand over the scroll by one rule: how far the reader moved the script
themselves, accumulated. Distance from the follow target could not tell a
catch-up still running from a reader who had moved, so touching the wheel
while the prompter eased towards a newly loaded event stopped it following.
Accumulating also measures a wheel gesture the browser spreads over many
frames, which the old per-event check sampled only once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
2026-08-25 11:12:10 +00:00
Claude 57362d85de fix(teleprompter): make follow-lock tolerant and its state legible
Two problems were feeding the confusion:

- Any wheel, touch, or pointerdown event broke the follow, with no tolerance.
  A stray touch or trackpad momentum after a deliberate gesture disengaged it
  as readily as a real scroll away from the read position, with no way to
  tell the two apart from the outside. The operator view solved the same
  problem with a distance check against the loaded item's expected position,
  which this now mirrors: it takes a real scroll (past 1.5 lines) to count as
  taking over, in a new hasBrokenFollow(), unit tested like the rest of the
  scroll maths.

- The runtime flag and the view option shared adjacent, opposite-valence
  names (followLocked / followLoaded) and were ANDed together in a third
  place to get the value the button actually needed. Renamed the runtime
  flag to autoScrollLocked, matching the operator's own lockAutoScroll for
  the same concept, and moved the AND into the hook so it exposes one signal
  the view no longer has to assemble itself.

Also fixes the threshold check reading a stale position: a burst of wheel
events can fire faster than the animation frame that keeps the scroll ref in
sync, so it now reads the element's scrollTop directly rather than the ref a
frame behind it. Caught by testing the tolerance in a live browser rather
than trusting the unit tests alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
2026-08-25 11:12:10 +00:00
Carlos Valente 23fd2ba881 bump version 2026-08-25 11:12:10 +00:00
Carlos Valente f655c47282 feat(teleprompter): add teleprompter view 2026-08-25 11:12:10 +00:00
42 changed files with 2828 additions and 115 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.12.0",
"version": "4.13.0-beta.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.12.0",
"version": "4.13.0-beta.1",
"private": true,
"type": "module",
"dependencies": {
+19 -2
View File
@@ -20,6 +20,7 @@ const Backstage = lazy(() => import('./views/backstage/Backstage'));
const StudioClock = lazy(() => import('./views/studio/Studio'));
const Timeline = lazy(() => import('./views/timeline/TimelinePage'));
const ProjectInfo = lazy(() => import('./views/project-info/ProjectInfo'));
const Teleprompter = lazy(() => import('./views/teleprompter/Teleprompter'));
const Editor = lazy(() => import('./views/editor/ProtectedEditor'));
const Cuesheet = lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
@@ -95,6 +96,15 @@ export default function AppRouter() {
</ViewLoader>
}
/>
<Route
path='teleprompter'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSpaceHotkey />
<Teleprompter />
</ViewLoader>
}
/>
{/*/!* Protected Routes *!/*/}
<Route path='editor' element={<Editor />} />
<Route path='cuesheet' element={<Cuesheet />} />
@@ -165,6 +175,7 @@ const PresetViewMap: Record<OntimeViewPresettable, ComponentType> = {
[OntimeView.StudioClock]: StudioClock,
[OntimeView.Countdown]: Countdown,
[OntimeView.ProjectInfo]: ProjectInfo,
[OntimeView.Teleprompter]: Teleprompter,
};
/**
@@ -214,8 +225,14 @@ function PresetView() {
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
return (
<PresetContext value={preset}>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
{Component ? <Component /> : <NotFound />}
<ViewLoader>
<ViewNavigationMenu
isNavigationLocked={getIsNavigationLocked()}
suppressSettings
suppressSpaceHotkey={preset.target === OntimeView.Teleprompter}
/>
{Component ? <Component /> : <NotFound />}
</ViewLoader>
</PresetContext>
);
}
@@ -0,0 +1,66 @@
.groups {
display: grid;
gap: 0.875rem;
}
.group {
h3 {
margin: 0 0 0.375rem;
color: var(--shortcut-title-color, currentColor);
font-size: calc(1rem - 3px);
font-weight: 600;
text-transform: uppercase;
}
}
.list {
display: grid;
gap: 0.25rem;
}
.row {
min-height: 1.625rem;
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
align-items: center;
gap: 0.75rem;
font-size: calc(1rem - 3px);
}
.label {
min-width: 0;
line-height: 1.2;
color: var(--shortcut-label-color, currentColor);
}
.keys {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.25rem 0.5rem;
min-width: 0;
}
.combo {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.25rem 0;
}
.separator {
color: var(--shortcut-separator-color, currentColor);
font-size: calc(1rem - 5px);
}
@media (max-width: 680px) {
.row {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.keys {
justify-content: flex-start;
}
}
@@ -0,0 +1,42 @@
import type { PropsWithChildren } from 'react';
import { cx } from '../../utils/styleUtils';
import Kbd from '../kbd/Kbd';
import style from './KeyboardShortcuts.module.scss';
export function ShortcutGroups({ className, children }: PropsWithChildren<{ className?: string }>) {
return <div className={cx([style.groups, className])}>{children}</div>;
}
export function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
return (
<section className={style.group}>
<h3>{title}</h3>
<div className={style.list}>{children}</div>
</section>
);
}
export function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.row}>
<span className={style.label}>{label}</span>
<span className={style.keys}>{children}</span>
</div>
);
}
export function Combo({ keys }: { keys: string[] }) {
return (
<span className={style.combo}>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</span>
);
}
export function Separator() {
return <span className={style.separator}>/</span>;
}
@@ -13,6 +13,8 @@ interface ViewNavigationMenuProps {
isNavigationLocked?: boolean;
/** prevent showing settings */
suppressSettings?: boolean;
/** leave Space to the view, which needs it for its own transport */
suppressSpaceHotkey?: boolean;
}
function isInteractiveKeyboardAction(target: EventTarget | null): boolean {
@@ -28,7 +30,7 @@ function isInteractiveKeyboardAction(target: EventTarget | null): boolean {
}
export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
function ViewNavigationMenu({ isNavigationLocked, suppressSettings, suppressSpaceHotkey }: ViewNavigationMenuProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams();
@@ -41,7 +43,7 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
[
'Space',
(event) => {
if (isNavigationLocked || isInteractiveKeyboardAction(event.target)) return;
if (suppressSpaceHotkey || isNavigationLocked || isInteractiveKeyboardAction(event.target)) return;
event.preventDefault();
toggleMenu();
},
@@ -50,7 +50,13 @@ export default function ParamInput({ paramField }: ParamInputProps) {
}
if (type === 'boolean') {
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) ?? defaultValue} />;
const paramValue = searchParams.get(id);
return (
<ControlledSwitch
id={id}
initialValue={paramValue === null ? Boolean(defaultValue) : isStringBoolean(paramValue)}
/>
);
}
if (type === 'number') {
@@ -228,11 +228,41 @@ describe('getURLSearchParamsFromObj()', () => {
},
];
const params = {
bool1: 'off',
bool2: 'on',
};
const result = getURLSearchParamsFromObj(params, mockOptionsWithBooleans);
expect(result.get('bool1')).toBe('false');
expect(result.get('bool2')).toBe('true');
});
it('omits booleans which match their default', () => {
const mockOptionsWithBooleans: ViewOption[] = [
{
title: OptionTitle.StyleOverride,
options: [
{
id: 'onByDefault',
title: 'onByDefault',
description: 'On by default',
type: 'boolean',
defaultValue: true,
},
{
id: 'offByDefault',
title: 'offByDefault',
description: 'Off by default',
type: 'boolean',
defaultValue: false,
},
],
},
];
const untouched = getURLSearchParamsFromObj({ onByDefault: 'on' }, mockOptionsWithBooleans);
expect(untouched.toString()).toBe('');
const flipped = getURLSearchParamsFromObj({ offByDefault: 'on' }, mockOptionsWithBooleans);
expect(flipped.get('onByDefault')).toBe('false');
expect(flipped.get('offByDefault')).toBe('true');
});
});
@@ -155,6 +155,14 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
});
});
// Unchecked checkboxes are absent from FormData. Persist false when true is the default.
metadata.booleanFields.forEach((id) => {
if (id in paramsObj) return;
if (metadata.defaultValues[id] !== 'false') {
addUniqueParam(id, 'false');
}
});
// Then process user-provided values
Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length) {
@@ -26,6 +26,7 @@ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
{ value: OntimeView.Timeline, label: viewLabels[OntimeView.Timeline] },
{ value: OntimeView.StudioClock, label: viewLabels[OntimeView.StudioClock] },
{ value: OntimeView.Countdown, label: viewLabels[OntimeView.Countdown] },
{ value: OntimeView.Teleprompter, label: viewLabels[OntimeView.Teleprompter] },
{ value: OntimeView.ProjectInfo, label: viewLabels[OntimeView.ProjectInfo] },
];
@@ -17,59 +17,10 @@
}
.shortcuts {
display: grid;
gap: 0.875rem;
margin-top: 0.875rem;
}
.shortcutGroup {
h3 {
margin: 0 0 0.375rem;
color: $ui-white;
font-size: calc(1rem - 3px);
font-weight: 600;
text-transform: uppercase;
}
}
.shortcutList {
display: grid;
gap: 0.25rem;
}
.shortcutRow {
min-height: 1.625rem;
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
align-items: center;
gap: 0.75rem;
font-size: calc(1rem - 3px);
}
.shortcutLabel {
min-width: 0;
line-height: 1.2;
}
.shortcutKeys {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.25rem 0.5rem;
min-width: 0;
}
.keyCombo {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.25rem 0;
}
.separator {
color: $gray-500;
font-size: calc(1rem - 5px);
--shortcut-title-color: #{$ui-white};
--shortcut-separator-color: #{$gray-500};
}
.prompt {
@@ -84,13 +35,4 @@
.shortcutSection {
margin-top: 1rem;
}
.shortcutRow {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.shortcutKeys {
justify-content: flex-start;
}
}
@@ -1,7 +1,13 @@
import { PropsWithChildren, memo } from 'react';
import { memo } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Kbd from '../../../common/components/kbd/Kbd';
import {
Combo,
Separator,
Shortcut,
ShortcutGroup,
ShortcutGroups,
} from '../../../common/components/keyboard-shortcuts/KeyboardShortcuts';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import style from './EventEditorEmpty.module.scss';
@@ -13,7 +19,7 @@ function EventEditorEmpty() {
<div className={style.entryEditor} data-testid='editor-container'>
<div className={style.shortcutSection}>
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
<div className={style.shortcuts}>
<ShortcutGroups className={style.shortcuts}>
<ShortcutGroup title='Search'>
<Shortcut label='Find in rundown'>
<Combo keys={[deviceMod, 'F']} />
@@ -100,40 +106,8 @@ function EventEditorEmpty() {
<Combo keys={[deviceAlt, 'Shift', 'D']} />
</Shortcut>
</ShortcutGroup>
</div>
</ShortcutGroups>
</div>
</div>
);
}
function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
return (
<section className={style.shortcutGroup}>
<h3>{title}</h3>
<div className={style.shortcutList}>{children}</div>
</section>
);
}
function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.shortcutRow}>
<span className={style.shortcutLabel}>{label}</span>
<span className={style.shortcutKeys}>{children}</span>
</div>
);
}
function Combo({ keys }: { keys: string[] }) {
return (
<span className={style.keyCombo}>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</span>
);
}
function Separator() {
return <span className={style.separator}>/</span>;
}
@@ -35,6 +35,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
{ value: OntimeView.Timer, label: 'Timer' },
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
{ value: OntimeView.Operator, label: 'Operator' },
{ value: OntimeView.Teleprompter, label: 'Teleprompter' },
{ value: '<<companion>>', label: 'Companion' },
...urlPresetData.map((preset) => ({
value: `preset-${preset.alias}`,
+2
View File
@@ -10,6 +10,7 @@ export const viewLabels: Record<OntimeView, string> = {
[OntimeView.Timeline]: 'Timeline',
[OntimeView.StudioClock]: 'Studio Clock',
[OntimeView.Countdown]: 'Countdown',
[OntimeView.Teleprompter]: 'Teleprompter',
[OntimeView.ProjectInfo]: 'Project Info',
};
@@ -20,6 +21,7 @@ const navigatorViews = [
OntimeView.Timeline,
OntimeView.StudioClock,
OntimeView.Countdown,
OntimeView.Teleprompter,
OntimeView.ProjectInfo,
];
@@ -0,0 +1,201 @@
@use '@/theme/viewerDefs' as *;
.teleprompter {
--tp-flip-x: 1;
--tp-flip-y: 1;
/* Keep shared stage-screen settings usable on smaller clients. */
--tp-font-size: min(var(--tp-configured-font-size), 8vw);
position: relative;
height: 100dvh;
width: 100%;
overflow: hidden;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, #000000);
color: var(--color-override, #ffffff);
transform: scale(var(--tp-flip-x), var(--tp-flip-y));
transform-origin: center center;
&--flip-h {
--tp-flip-x: -1;
}
&--flip-v {
--tp-flip-y: -1;
}
}
.teleprompter__scroller {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
/* The animation loop is the only writer of scrollTop. */
overflow-anchor: none;
scroll-behavior: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.teleprompter__content {
width: var(--tp-text-width);
margin-inline: auto;
font-size: var(--tp-font-size);
line-height: var(--tp-line-height);
/* Percent padding resolves against width, so use viewport-height units. */
padding-top: calc(var(--tp-reading-line) * 1dvh);
padding-bottom: calc(100dvh - var(--tp-reading-line) * 1dvh);
}
.teleprompter__block {
margin-bottom: 1em;
&[data-loaded] .teleprompter__heading {
color: $accent-color;
}
}
.teleprompter__group {
font-size: 0.34em;
text-transform: uppercase;
letter-spacing: 0.1em;
color: $viewer-label-color;
margin-bottom: 0.35em;
}
.teleprompter__heading {
font-size: 0.38em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $viewer-secondary-color;
margin-bottom: 0.3em;
}
.teleprompter__body {
white-space: pre-wrap;
overflow-wrap: break-word;
}
.teleprompter__dim {
position: absolute;
inset: 0 0 auto 0;
height: calc(var(--tp-reading-line) * 1%);
pointer-events: none;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0));
}
.teleprompter__reading-line {
position: absolute;
top: calc(var(--tp-reading-line) * 1%);
left: 0;
right: 0;
width: var(--tp-text-width);
margin-inline: auto;
height: calc(var(--tp-font-size) * var(--tp-line-height));
pointer-events: none;
}
.teleprompter__reading-marker {
position: absolute;
top: 0;
bottom: 0;
background: $accent-color;
clip-path: polygon(0 0, 100% 50%, 0 100%);
font-size: var(--tp-font-size);
--tp-gutter: max(0px, calc((100vw - 100%) / 2));
width: min(1.2em, calc(var(--tp-gutter) * 0.6));
right: calc(100% + var(--tp-gutter) * 0.2);
}
.teleprompter__controls {
position: absolute;
bottom: min(2vh, 16px);
left: 50%;
/* Cancel the view flip so controls remain readable to the operator. */
transform: translateX(-50%) scale(var(--tp-flip-x), var(--tp-flip-y));
display: flex;
align-items: center;
gap: min(1vh, 8px);
padding: min(1vh, 8px) clamp(8px, 1vw, 16px);
background: rgba(white, 8%);
border-radius: $element-border-radius;
backdrop-filter: blur(6px);
opacity: 1;
transition: opacity $viewer-transition-time;
&--idle {
opacity: 0;
pointer-events: none;
}
}
.teleprompter__speed {
display: flex;
align-items: baseline;
gap: 0.2em;
min-width: 4em;
justify-content: center;
font-size: clamp(14px, 1.4vw, 22px);
font-variant-numeric: tabular-nums;
}
.teleprompter__speed-unit {
font-size: 0.6em;
color: $viewer-label-color;
}
.teleprompter__help {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
}
.teleprompter__help-card {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(92vw, 32rem);
max-height: 85dvh;
overflow-y: auto;
padding: 1.5rem;
background: $viewer-background-color;
color: $viewer-color;
border-radius: $element-border-radius;
font-size: 1rem;
}
.teleprompter__help-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
.teleprompter__help-title {
font-size: 1.25rem;
font-weight: 600;
}
.teleprompter__help-groups {
--shortcut-title-color: #{$viewer-label-color};
--shortcut-label-color: #{$viewer-secondary-color};
--shortcut-separator-color: #{$viewer-label-color};
}
@@ -0,0 +1,159 @@
import { OntimeView } from 'ontime-types';
import { type CSSProperties, useState } from 'react';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useSelectedEventId } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { cx } from '../../common/utils/styleUtils';
import Loader from '../common/loader/Loader';
import ControlOverlay from './control-overlay/ControlOverlay';
import HelpOverlay from './help-overlay/HelpOverlay';
import ScriptBlockView from './script-block/ScriptBlock';
import { defaults, getTeleprompterOptions, useTeleprompterOptions } from './teleprompter.options';
import { stepFontSize } from './teleprompter.scroll';
import { buildScript, composeFlip } from './teleprompter.utils';
import { useSyncTeleprompterParams } from './useSyncTeleprompterParams';
import { useTeleprompterControls } from './useTeleprompterControls';
import { type TeleprompterData, useTeleprompterData } from './useTeleprompterData';
import { useTeleprompterScroll } from './useTeleprompterScroll';
import './Teleprompter.scss';
export default function TeleprompterLoader() {
const { data, status } = useTeleprompterData();
useWindowTitle('Teleprompter');
if (status === 'pending') {
return <Loader />;
}
if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Teleprompter {...data} />;
}
function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterData) {
'use memo';
const options = useTeleprompterOptions();
const selectedEventId = useSelectedEventId();
const isMirrored = useViewOptionsStore((state) => state.mirror);
const [showHelp, setShowHelp] = useState(false);
const fromParams = { flipH: options.flipH, flipV: options.flipV, fontSize: options.fontSize };
const paramsKey = `${fromParams.flipH}|${fromParams.flipV}|${fromParams.fontSize}`;
const [live, setLive] = useState(fromParams);
const [seededFrom, setSeededFrom] = useState(paramsKey);
// Reset live controls before commit when the URL configuration changes.
if (seededFrom !== paramsKey) {
setSeededFrom(paramsKey);
setLive(fromParams);
}
const viewOptions = getTeleprompterOptions(customFields);
const blocks = buildScript(rundown, rundownMetadata, customFields, {
scriptSource: options.scriptSource,
heading: options.heading,
hideEmpty: options.hideEmpty,
showGroups: options.showGroups,
});
const { scrollerRef, contentRef, registerBlock, controller, isRunning, speed, canReengageFollow, atEnd } =
useTeleprompterScroll({
initialSpeed: options.speed,
followLoaded: options.followLoaded,
selectedEventId,
readingLinePos: options.readingLinePos,
blocks,
});
const handleFlip = (axis: 'h' | 'v') =>
setLive((current) => {
const key = axis === 'h' ? 'flipH' : 'flipV';
return { ...current, [key]: !current[key] };
});
const handleFontSize = (steps: number) =>
setLive((current) => ({ ...current, fontSize: stepFontSize(current.fontSize, steps) }));
const handleResetFontSize = () => setLive((current) => ({ ...current, fontSize: defaults.fontSize }));
const handleToggleHelp = () => setShowHelp((current) => !current);
useSyncTeleprompterParams({ speed, fontSize: live.fontSize, flipH: live.flipH, flipV: live.flipV });
useTeleprompterControls({
controller,
isHelpOpen: showHelp,
onFlip: handleFlip,
onFontSize: handleFontSize,
onResetFontSize: handleResetFontSize,
onToggleHelp: handleToggleHelp,
});
const hasScriptSource = options.scriptSource !== 'none';
const effectiveFlip = composeFlip(live.flipH, live.flipV, isMirrored);
const viewStyles = {
'--tp-configured-font-size': `${live.fontSize}px`,
'--tp-line-height': options.lineHeight,
'--tp-text-width': `${options.textWidth}%`,
'--tp-reading-line': options.readingLinePos,
} as CSSProperties;
return (
<div
className={cx([
'teleprompter',
effectiveFlip.flipH && 'teleprompter--flip-h',
effectiveFlip.flipV && 'teleprompter--flip-v',
])}
style={viewStyles}
data-testid='teleprompter-view'
>
<ViewParamsEditor target={OntimeView.Teleprompter} viewOptions={viewOptions} />
{!hasScriptSource ? (
<EmptyPage text='Select which field holds the script in the view options' />
) : blocks.length === 0 ? (
<EmptyPage text='There is no script text in the selected field' />
) : (
<>
<div className='teleprompter__scroller' data-testid='teleprompter-scroller' ref={scrollerRef}>
<div className='teleprompter__content' ref={contentRef}>
{blocks.map((block) => (
<ScriptBlockView key={block.id} block={block} registerRef={registerBlock} />
))}
</div>
</div>
<div className='teleprompter__dim' />
{options.readingLine && (
<div className='teleprompter__reading-line'>
<span className='teleprompter__reading-marker' />
</div>
)}
<ControlOverlay
isRunning={isRunning}
speed={speed}
canReengageFollow={canReengageFollow}
atEnd={atEnd}
controller={controller}
onToggleHelp={handleToggleHelp}
/>
</>
)}
<HelpOverlay isOpen={showHelp} onClose={handleToggleHelp} />
</div>
);
}
@@ -0,0 +1,107 @@
import { resolveTeleprompterAction, type TeleprompterKeyEvent } from '../teleprompter.keymap';
import { SPEED_STEP, SPEED_STEP_COARSE } from '../teleprompter.scroll';
function makeEvent(overrides: Partial<TeleprompterKeyEvent>): TeleprompterKeyEvent {
return {
code: '',
key: '',
shiftKey: false,
ctrlKey: false,
metaKey: false,
altKey: false,
repeat: false,
...overrides,
};
}
describe('resolveTeleprompterAction()', () => {
test('space toggles playback', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'Space' }))).toEqual({ type: 'togglePlay' });
});
test('ignores a repeating space', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'Space', repeat: true }))).toBeNull();
});
test('vertical arrows nudge by a line', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowDown' }))).toEqual({ type: 'nudge', lines: 1 });
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowUp' }))).toEqual({ type: 'nudge', lines: -1 });
});
test('a repeating arrow still nudges, so the key can be held', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowDown', repeat: true }))).toEqual({
type: 'nudge',
lines: 1,
});
});
test('shift makes the vertical arrows jump a whole event', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowDown', shiftKey: true }))).toEqual({
type: 'jumpEvent',
direction: 1,
});
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowUp', shiftKey: true }))).toEqual({
type: 'jumpEvent',
direction: -1,
});
});
test('page keys jump a screen', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'PageDown' }))).toEqual({ type: 'page', direction: 1 });
expect(resolveTeleprompterAction(makeEvent({ code: 'PageUp' }))).toEqual({ type: 'page', direction: -1 });
});
test('horizontal arrows change speed', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight' }))).toEqual({ type: 'speed', delta: SPEED_STEP });
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowLeft' }))).toEqual({ type: 'speed', delta: -SPEED_STEP });
});
test('shift makes the speed step coarse', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight', shiftKey: true }))).toEqual({
type: 'speed',
delta: SPEED_STEP_COARSE,
});
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowLeft', shiftKey: true }))).toEqual({
type: 'speed',
delta: -SPEED_STEP_COARSE,
});
});
test('home rewinds and escape rewinds and stops', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'Home' }))).toEqual({ type: 'rewind' });
expect(resolveTeleprompterAction(makeEvent({ code: 'Escape' }))).toEqual({ type: 'rewindAndPause' });
expect(resolveTeleprompterAction(makeEvent({ code: 'End' }))).toEqual({ type: 'jumpToEnd' });
});
test('f flips, shift+f flips the other axis', () => {
expect(resolveTeleprompterAction(makeEvent({ key: 'f' }))).toEqual({ type: 'flip', axis: 'h' });
expect(resolveTeleprompterAction(makeEvent({ key: 'F', shiftKey: true }))).toEqual({ type: 'flip', axis: 'v' });
});
test('changes and resets the font size', () => {
expect(resolveTeleprompterAction(makeEvent({ key: '+' }))).toEqual({ type: 'fontSize', steps: 1 });
expect(resolveTeleprompterAction(makeEvent({ key: '=' }))).toEqual({ type: 'fontSize', steps: 1 });
expect(resolveTeleprompterAction(makeEvent({ key: '-' }))).toEqual({ type: 'fontSize', steps: -1 });
expect(resolveTeleprompterAction(makeEvent({ key: '_' }))).toEqual({ type: 'fontSize', steps: -1 });
expect(resolveTeleprompterAction(makeEvent({ key: '0' }))).toEqual({ type: 'resetFontSize' });
});
test('l re-engages the follow and ? shows the help', () => {
expect(resolveTeleprompterAction(makeEvent({ key: 'l' }))).toEqual({ type: 'reengageFollow' });
expect(resolveTeleprompterAction(makeEvent({ key: '?', shiftKey: true }))).toEqual({ type: 'toggleHelp' });
});
test('never shadows a shortcut which carries a modifier', () => {
expect(resolveTeleprompterAction(makeEvent({ key: ',', metaKey: true }))).toBeNull();
expect(resolveTeleprompterAction(makeEvent({ code: 'Space', ctrlKey: true }))).toBeNull();
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight', altKey: true }))).toBeNull();
});
test('ignores keys it does not bind', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'KeyQ', key: 'q' }))).toBeNull();
});
test('leaves Enter alone, so a focused transport button can still be pressed', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'Enter', key: 'Enter' }))).toBeNull();
});
});
@@ -0,0 +1,109 @@
import { getOptionsFromParams, getTeleprompterOptions } from '../teleprompter.options';
import { DEFAULT_SPEED, MAX_SPEED, MIN_SPEED } from '../teleprompter.scroll';
describe('getOptionsFromParams()', () => {
test('provides sensible defaults with no params', () => {
const options = getOptionsFromParams(new URLSearchParams());
expect(options).toMatchObject({
scriptSource: 'none',
heading: 'title',
hideEmpty: true,
showGroups: true,
speed: DEFAULT_SPEED,
followLoaded: true,
fontSize: 52,
lineHeight: 1.3,
textWidth: 80,
readingLine: true,
readingLinePos: 25,
flipH: false,
flipV: false,
});
});
test('reads the script source verbatim so it can be handed to getPropertyValue', () => {
const options = getOptionsFromParams(new URLSearchParams('script=custom-prompter'));
expect(options.scriptSource).toBe('custom-prompter');
});
test('booleans which default to true can be turned off', () => {
const options = getOptionsFromParams(
new URLSearchParams('hideEmpty=false&showGroups=false&followLoaded=false&readingLine=false'),
);
expect(options).toMatchObject({
hideEmpty: false,
showGroups: false,
followLoaded: false,
readingLine: false,
});
});
test('booleans which default to false can be turned on', () => {
const options = getOptionsFromParams(new URLSearchParams('flipH=true&flipV=true'));
expect(options).toMatchObject({
flipH: true,
flipV: true,
});
});
test('clamps the speed to the usable range', () => {
expect(getOptionsFromParams(new URLSearchParams('speed=1000')).speed).toBe(MAX_SPEED);
expect(getOptionsFromParams(new URLSearchParams('speed=0')).speed).toBe(MIN_SPEED);
});
test('falls back to the default for a non numeric value', () => {
expect(getOptionsFromParams(new URLSearchParams('speed=fast')).speed).toBe(DEFAULT_SPEED);
expect(getOptionsFromParams(new URLSearchParams('speed=')).speed).toBe(DEFAULT_SPEED);
expect(getOptionsFromParams(new URLSearchParams('fontSize=huge')).fontSize).toBe(52);
});
test('rejects an unknown value for an enumerated option', () => {
expect(getOptionsFromParams(new URLSearchParams('heading=banana')).heading).toBe('title');
});
test('preset values take precedence over the search params', () => {
const options = getOptionsFromParams(
new URLSearchParams('speed=10&script=custom-a'),
new URLSearchParams('speed=20&script=custom-b'),
);
expect(options.speed).toBe(20);
expect(options.scriptSource).toBe('custom-b');
});
});
describe('getTeleprompterOptions()', () => {
test('every declared default is what parsing an empty query produces', () => {
const parsed = getOptionsFromParams(new URLSearchParams()) as Record<string, unknown>;
const parsedByParamId: Record<string, unknown> = { ...parsed, script: parsed.scriptSource };
const declared = getTeleprompterOptions({}).flatMap((section) => section.options);
expect(declared.length).toBeGreaterThan(0);
for (const field of declared) {
if (!('defaultValue' in field) || field.defaultValue === undefined) continue;
const expected = field.defaultValue;
expect({ id: field.id, value: parsedByParamId[field.id] }).toEqual({ id: field.id, value: expected });
}
});
});
describe('option value round trip', () => {
test('every value the editor offers survives parsing', () => {
const selects = getTeleprompterOptions({})
.flatMap((section) => section.options)
.filter((field) => field.type === 'option' && field.id !== 'script');
expect(selects.length).toBeGreaterThan(0);
for (const field of selects) {
for (const { value } of field.values) {
const parsed = getOptionsFromParams(new URLSearchParams(`${field.id}=${value}`)) as Record<string, unknown>;
expect({ id: field.id, value, parsed: parsed[field.id] }).toEqual({ id: field.id, value, parsed: value });
}
}
});
});
@@ -0,0 +1,247 @@
import {
advance,
anchorAtReadPoint,
type BlockGeometry,
clampSpeed,
easeCatchUp,
FOLLOW_BREAK_LINES,
frameDeltaSeconds,
hasBrokenFollow,
indexAtReadPoint,
linesPerMinuteToPxPerSecond,
MAX_FONT_SIZE,
MAX_FRAME_DELTA_MS,
MAX_SPEED,
MIN_FONT_SIZE,
MIN_SPEED,
readPointForAnchor,
stepFontSize,
} from '../teleprompter.scroll';
describe('linesPerMinuteToPxPerSecond()', () => {
test('converts a read rate into a pixel rate', () => {
// 30 lines a minute over a 64px line is half a line a second
expect(linesPerMinuteToPxPerSecond(30, 64)).toBe(32);
});
});
describe('stepFontSize()', () => {
test('steps by a ratio, so a press is the same visual change at any size', () => {
// a fixed pixel step would be a big jump at 20px and imperceptible at 200px
expect(stepFontSize(100, 1)).toBe(110);
expect(stepFontSize(20, 1)).toBe(22);
});
test('shrinking undoes growing', () => {
expect(stepFontSize(stepFontSize(50, 1), -1)).toBe(50);
});
test('stays inside the range the option accepts', () => {
expect(stepFontSize(MAX_FONT_SIZE, 5)).toBe(MAX_FONT_SIZE);
expect(stepFontSize(MIN_FONT_SIZE, -5)).toBe(MIN_FONT_SIZE);
});
});
describe('clampSpeed()', () => {
test('bounds the speed to the usable range', () => {
expect(clampSpeed(MIN_SPEED - 10)).toBe(MIN_SPEED);
expect(clampSpeed(MAX_SPEED + 10)).toBe(MAX_SPEED);
expect(clampSpeed(30)).toBe(30);
});
test('falls back to the minimum for a non number, rather than stalling at zero', () => {
expect(clampSpeed(Number.NaN)).toBe(MIN_SPEED);
});
});
describe('frameDeltaSeconds()', () => {
test('clamps a long gap so returning to a background tab cannot teleport the script', () => {
// requestAnimationFrame is suspended while hidden, so the first timestamp
// back can be minutes stale
expect(frameDeltaSeconds(1000 / 60)).toBeCloseTo(1 / 60, 6);
expect(frameDeltaSeconds(60_000)).toBe(MAX_FRAME_DELTA_MS / 1000);
});
test('ignores nonsense deltas', () => {
expect(frameDeltaSeconds(-5)).toBe(0);
expect(frameDeltaSeconds(Number.NaN)).toBe(0);
});
});
describe('advance()', () => {
test('accumulates sub-pixel movement without losing any to rounding', () => {
// 32px/s sampled at 60fps is 0.53px a frame: rounding each frame would stall
const pxPerSecond = 32;
const frame = 1 / 60;
let position = 0;
for (let i = 0; i < 100; i += 1) {
position = advance(position, pxPerSecond, frame, 10_000).position;
}
expect(position).toBeCloseTo((pxPerSecond * 100) / 60, 5);
});
test('reports the end of the script once the bottom is reached', () => {
expect(advance(499, 100, 1, 500).atEnd).toBe(true);
expect(advance(100, 100, 1, 500).atEnd).toBe(false);
});
test('reports the end without bounding the position, which the caller owns', () => {
// the loop has to bound the position anyway, for nudges and for a document
// which shrank, so this does not do it a second time
expect(advance(490, 100, 1, 500)).toEqual({ position: 590, atEnd: true });
});
test('never reports the end for a document which does not overflow', () => {
// it may simply not have been measured yet, and stopping playback on an
// unmeasured document would look like the prompter refusing to run
expect(advance(0, 100, 1, 0).atEnd).toBe(false);
});
});
describe('hasBrokenFollow()', () => {
const lineHeight = 40;
test('tolerates drift under the threshold, so momentum or a stray touch does not break it', () => {
const underThreshold = lineHeight * FOLLOW_BREAK_LINES - 1;
expect(hasBrokenFollow(underThreshold, lineHeight)).toBe(false);
expect(hasBrokenFollow(-underThreshold, lineHeight)).toBe(false);
});
test('counts a deliberate scroll past the threshold, in either direction', () => {
const overThreshold = lineHeight * FOLLOW_BREAK_LINES + 1;
expect(hasBrokenFollow(overThreshold, lineHeight)).toBe(true);
expect(hasBrokenFollow(-overThreshold, lineHeight)).toBe(true);
});
test('never breaks follow before the document has been measured', () => {
// an unmeasured line height would make any distance look like a break
expect(hasBrokenFollow(10_000, 0)).toBe(false);
});
});
describe('the read anchor', () => {
const script: BlockGeometry[] = [
{ id: 'welcome', top: 0, height: 100 },
{ id: 'keynote', top: 100, height: 300 },
{ id: 'lunch', top: 400, height: 100 },
];
const ids = script.map((block) => block.id);
describe('indexAtReadPoint()', () => {
test('finds the block the reading line is over', () => {
expect(indexAtReadPoint(150, script)).toBe(1);
expect(indexAtReadPoint(400, script)).toBe(2);
});
test('clamps past either end, so a jump from there still lands on a block', () => {
expect(indexAtReadPoint(-50, script)).toBe(0);
expect(indexAtReadPoint(10_000, script)).toBe(2);
});
test('reports no block for an empty script', () => {
expect(indexAtReadPoint(0, [])).toBe(-1);
});
});
describe('readPointForAnchor()', () => {
test('round trips an unchanged document', () => {
const anchor = anchorAtReadPoint(250, script);
expect(anchor).toEqual({ blockId: 'keynote', offset: 150 });
expect(readPointForAnchor(anchor!, script, ids)).toBe(250);
});
test('holds the same words under the reading line when an event above grows', () => {
// the whole point: the rundown is edited while it is being read, and an
// edit above the reader moves every pixel below it
const anchor = anchorAtReadPoint(250, script);
const grown: BlockGeometry[] = [
{ id: 'welcome', top: 0, height: 180 },
{ id: 'keynote', top: 180, height: 300 },
{ id: 'lunch', top: 480, height: 100 },
];
expect(readPointForAnchor(anchor!, grown, ids)).toBe(330);
});
test('follows the anchored event when the rundown is reordered', () => {
const anchor = anchorAtReadPoint(250, script);
const reordered: BlockGeometry[] = [
{ id: 'lunch', top: 0, height: 100 },
{ id: 'welcome', top: 100, height: 100 },
{ id: 'keynote', top: 200, height: 300 },
];
expect(readPointForAnchor(anchor!, reordered, ids)).toBe(350);
});
test('stays inside an event which was edited shorter than the read offset', () => {
const anchor = anchorAtReadPoint(250, script);
const trimmed: BlockGeometry[] = [
{ id: 'welcome', top: 0, height: 100 },
{ id: 'keynote', top: 100, height: 40 },
{ id: 'lunch', top: 140, height: 100 },
];
expect(readPointForAnchor(anchor!, trimmed, ids)).toBe(140);
});
test('falls back to the end of the nearest surviving event when the anchored one is deleted', () => {
// where the deleted text used to begin, rather than wherever its pixels
// now happen to point
const anchor = anchorAtReadPoint(250, script);
const deleted: BlockGeometry[] = [
{ id: 'welcome', top: 0, height: 100 },
{ id: 'lunch', top: 100, height: 100 },
];
expect(readPointForAnchor(anchor!, deleted, ids)).toBe(100);
});
test('gives up rather than guessing when nothing before the anchor survives', () => {
const anchor = anchorAtReadPoint(250, script);
expect(readPointForAnchor(anchor!, [{ id: 'lunch', top: 0, height: 100 }], ids)).toBeNull();
});
});
});
describe('easeCatchUp()', () => {
test('approaches the target monotonically from either side', () => {
let fromAbove = 500;
let fromBelow = 0;
let previousAbove = 501;
let previousBelow = -1;
for (let i = 0; i < 20; i += 1) {
fromBelow = easeCatchUp(fromBelow, 500, 1 / 60);
fromAbove = easeCatchUp(fromAbove, 0, 1 / 60);
expect(fromBelow).toBeGreaterThan(previousBelow);
expect(fromBelow).toBeLessThanOrEqual(500);
expect(fromAbove).toBeLessThan(previousAbove);
expect(fromAbove).toBeGreaterThanOrEqual(0);
previousBelow = fromBelow;
previousAbove = fromAbove;
}
});
test('settles exactly on the target instead of creeping forever', () => {
let position = 0;
for (let i = 0; i < 300; i += 1) {
position = easeCatchUp(position, 500, 1 / 60);
}
expect(position).toBe(500);
});
test('is framerate independent, so a jump takes the same time on any display', () => {
let atSixty = 0;
for (let i = 0; i < 60; i += 1) {
atSixty = easeCatchUp(atSixty, 1000, 1 / 60);
}
let atThirty = 0;
for (let i = 0; i < 30; i += 1) {
atThirty = easeCatchUp(atThirty, 1000, 1 / 30);
}
expect(atSixty).toBeCloseTo(atThirty, 3);
});
});
@@ -0,0 +1,218 @@
import { type CustomFields, type OntimeEntry, type Rundown, SupportedEntry } from 'ontime-types';
import type { RundownMetadata, RundownMetadataObject } from '../../../common/utils/rundownMetadata';
import { buildScript, composeFlip } from '../teleprompter.utils';
function makeEvent(id: string, overrides: Partial<OntimeEntry> = {}): OntimeEntry {
return {
type: SupportedEntry.Event,
id,
cue: id.toUpperCase(),
title: `Title ${id}`,
note: `Note ${id}`,
skip: false,
custom: { script: `Script ${id}` },
parent: null,
...overrides,
} as OntimeEntry;
}
function makeGroup(id: string, title: string, entries: string[]): OntimeEntry {
return { type: SupportedEntry.Group, id, title, entries } as OntimeEntry;
}
function makeMetadata(overrides: Partial<RundownMetadata> = {}): RundownMetadata {
return { isPast: false, isLoaded: false, groupId: null, ...overrides } as RundownMetadata;
}
function makeRundown(entries: OntimeEntry[], flatOrder?: string[]): Rundown {
return {
id: 'default',
title: 'test',
order: flatOrder ?? entries.map((entry) => entry.id),
flatOrder: flatOrder ?? entries.map((entry) => entry.id),
entries: Object.fromEntries(entries.map((entry) => [entry.id, entry])),
revision: 1,
};
}
const customFields: CustomFields = {
script: { type: 'text', colour: '', label: 'Script' },
poster: { type: 'image', colour: '', label: 'Poster' },
};
const defaultOptions = {
scriptSource: 'custom-script',
heading: 'title' as const,
hideEmpty: true,
showGroups: true,
};
function metadataFor(ids: string[], overrides: Record<string, Partial<RundownMetadata>> = {}): RundownMetadataObject {
return Object.fromEntries(ids.map((id) => [id, makeMetadata(overrides[id])]));
}
describe('buildScript()', () => {
test('resolves the script from the selected custom field, in rundown order', () => {
const rundown = makeRundown([makeEvent('a'), makeEvent('b')]);
const blocks = buildScript(rundown, metadataFor(['a', 'b']), customFields, defaultOptions);
expect(blocks).toHaveLength(2);
expect(blocks.map((block) => block.id)).toEqual(['a', 'b']);
expect(blocks[0].text).toBe('Script a');
});
test('returns nothing when no script source is selected', () => {
const rundown = makeRundown([makeEvent('a')]);
expect(buildScript(rundown, metadataFor(['a']), customFields, { ...defaultOptions, scriptSource: 'none' })).toEqual(
[],
);
});
test('refuses an image custom field, which the select filters but the URL does not', () => {
const rundown = makeRundown([makeEvent('a', { custom: { poster: 'https://example.com/a.png' } })]);
const blocks = buildScript(rundown, metadataFor(['a']), customFields, {
...defaultOptions,
scriptSource: 'custom-poster',
});
expect(blocks).toEqual([]);
});
test('reads the note and the title as script sources', () => {
const rundown = makeRundown([makeEvent('a')]);
expect(
buildScript(rundown, metadataFor(['a']), customFields, { ...defaultOptions, scriptSource: 'note' })[0].text,
).toBe('Note a');
expect(
buildScript(rundown, metadataFor(['a']), customFields, { ...defaultOptions, scriptSource: 'title' })[0].text,
).toBe('Title a');
});
test('skips entries which are not events', () => {
const rundown = makeRundown([
makeEvent('a'),
{ type: SupportedEntry.Delay, id: 'd', duration: 10 } as OntimeEntry,
{ type: SupportedEntry.Milestone, id: 'm', title: 'milestone' } as OntimeEntry,
]);
const blocks = buildScript(rundown, metadataFor(['a', 'd', 'm']), customFields, defaultOptions);
expect(blocks.map((block) => block.id)).toEqual(['a']);
});
test('skips events flagged as skipped', () => {
const rundown = makeRundown([makeEvent('a', { skip: true }), makeEvent('b')]);
const blocks = buildScript(rundown, metadataFor(['a', 'b']), customFields, defaultOptions);
expect(blocks.map((block) => block.id)).toEqual(['b']);
});
test('hideEmpty drops events with no script text', () => {
const rundown = makeRundown([makeEvent('a', { custom: { script: ' ' } }), makeEvent('b')]);
expect(buildScript(rundown, metadataFor(['a', 'b']), customFields, defaultOptions).map((b) => b.id)).toEqual(['b']);
expect(
buildScript(rundown, metadataFor(['a', 'b']), customFields, { ...defaultOptions, hideEmpty: false }).map(
(b) => b.id,
),
).toEqual(['a', 'b']);
});
test('marks the block belonging to the loaded event', () => {
const rundown = makeRundown([makeEvent('a'), makeEvent('b')]);
const metadata = metadataFor(['a', 'b'], { b: { isLoaded: true } });
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
expect(blocks.map((block) => block.isLoaded)).toEqual([false, true]);
});
test('emits a group title once, on the first block of the group', () => {
const rundown = makeRundown(
[makeGroup('g', 'Morning session', ['a', 'b']), makeEvent('a'), makeEvent('b')],
['g', 'a', 'b'],
);
const metadata = metadataFor(['g', 'a', 'b'], { a: { groupId: 'g' }, b: { groupId: 'g' } });
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', null]);
});
test('emits each group title as the script moves between groups', () => {
const rundown = makeRundown(
[
makeGroup('g1', 'Morning session', ['a']),
makeEvent('a'),
makeGroup('g2', 'Afternoon session', ['b']),
makeEvent('b'),
],
['g1', 'a', 'g2', 'b'],
);
const metadata = metadataFor(['g1', 'a', 'g2', 'b'], { a: { groupId: 'g1' }, b: { groupId: 'g2' } });
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', 'Afternoon session']);
});
test('repeats a group title when the script returns to it after an ungrouped event', () => {
// the reader has lost the context by then, so naming the group again is right
const rundown = makeRundown(
[makeGroup('g', 'Morning session', ['a', 'c']), makeEvent('a'), makeEvent('b'), makeEvent('c')],
['g', 'a', 'b', 'c'],
);
const metadata = metadataFor(['g', 'a', 'b', 'c'], { a: { groupId: 'g' }, c: { groupId: 'g' } });
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', null, 'Morning session']);
});
test('does not emit group titles when they are turned off', () => {
const rundown = makeRundown([makeGroup('g', 'Morning session', ['a']), makeEvent('a')], ['g', 'a']);
const metadata = metadataFor(['g', 'a'], { a: { groupId: 'g' } });
const blocks = buildScript(rundown, metadata, customFields, { ...defaultOptions, showGroups: false });
expect(blocks[0].groupTitle).toBeNull();
});
describe('headings', () => {
const rundown = makeRundown([makeEvent('a')]);
const metadata = metadataFor(['a']);
test('shows the title, the cue, both, or nothing', () => {
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'title' })[0].heading).toBe(
'Title a',
);
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'cue' })[0].heading).toBe('A');
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'both' })[0].heading).toBe(
'A · Title a',
);
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'none' })[0].heading).toBe('');
});
test('leaves no dangling separator when an event has no cue', () => {
const noCue = makeRundown([makeEvent('a', { cue: '' })]);
expect(buildScript(noCue, metadata, customFields, { ...defaultOptions, heading: 'both' })[0].heading).toBe(
'Title a',
);
});
});
});
describe('composeFlip()', () => {
test('passes the per view flips through when Flip Screen is off', () => {
expect(composeFlip(false, false, false)).toEqual({ flipH: false, flipV: false });
expect(composeFlip(true, false, false)).toEqual({ flipH: true, flipV: false });
expect(composeFlip(false, true, false)).toEqual({ flipH: false, flipV: true });
});
test('Flip Screen alone flips both axes, matching rotate(180deg) in every other view', () => {
expect(composeFlip(false, false, true)).toEqual({ flipH: true, flipV: true });
});
test('a horizontal flip and Flip Screen leave only the vertical axis flipped', () => {
// scale(-1, 1) composed with scale(-1, -1) is scale(1, -1)
expect(composeFlip(true, false, true)).toEqual({ flipH: false, flipV: true });
});
test('both flips cancel Flip Screen out', () => {
expect(composeFlip(true, true, true)).toEqual({ flipH: false, flipV: false });
});
});
@@ -0,0 +1,133 @@
import type { MouseEvent } from 'react';
import { IoAdd, IoArrowUp, IoHelpCircleOutline, IoLocate, IoPause, IoPlay, IoRemove } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import { cx } from '../../../common/utils/styleUtils';
import { SPEED_STEP } from '../teleprompter.scroll';
import type { TeleprompterController } from '../teleprompter.types';
interface ControlOverlayProps {
isRunning: boolean;
speed: number;
canReengageFollow: boolean;
atEnd: boolean;
controller: TeleprompterController;
onToggleHelp: () => void;
}
export default function ControlOverlay({
isRunning,
speed,
canReengageFollow,
atEnd,
controller,
onToggleHelp,
}: ControlOverlayProps) {
const isActive = useFadeOutOnInactivity(true);
// Pointer activation yields focus so the next Space reaches the transport.
const press = (action: () => void) => (event: MouseEvent<HTMLButtonElement>) => {
if (event.detail > 0) {
event.currentTarget.blur();
}
action();
};
return (
<div className={cx(['teleprompter__controls', !isActive && 'teleprompter__controls--idle'])}>
<Tooltip
text={isRunning ? 'Pause (Space)' : 'Play (Space)'}
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(controller.togglePlay)}
data-testid='teleprompter-play'
aria-label={isRunning ? 'Pause' : 'Play'}
/>
}
>
{isRunning ? <IoPause /> : <IoPlay />}
</Tooltip>
<Tooltip
text='Slow down (Left arrow)'
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(() => controller.changeSpeed(-SPEED_STEP))}
aria-label='Slow down'
/>
}
>
<IoRemove />
</Tooltip>
<div className='teleprompter__speed' data-testid='teleprompter-speed'>
{speed}
<span className='teleprompter__speed-unit'>lpm</span>
</div>
<Tooltip
text='Speed up (Right arrow)'
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(() => controller.changeSpeed(SPEED_STEP))}
aria-label='Speed up'
/>
}
>
<IoAdd />
</Tooltip>
<Tooltip
text='Rewind to the top (Home)'
render={
<IconButton
variant={atEnd ? 'primary' : 'subtle-white'}
size='large'
onClick={press(() => controller.rewind())}
aria-label='Rewind to top'
/>
}
>
<IoArrowUp />
</Tooltip>
<Tooltip
text={canReengageFollow ? 'Resume following the loaded event (L)' : 'Following the loaded event'}
render={
<IconButton
variant={canReengageFollow ? 'primary' : 'subtle-white'}
size='large'
disabled={!canReengageFollow}
onClick={press(controller.reengageFollow)}
data-testid='teleprompter-follow'
aria-label='Follow the loaded event'
/>
}
>
<IoLocate />
</Tooltip>
<Tooltip
text='Keyboard shortcuts (?)'
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(onToggleHelp)}
aria-label='Keyboard shortcuts'
/>
}
>
<IoHelpCircleOutline />
</Tooltip>
</div>
);
}
@@ -0,0 +1,107 @@
import { Dialog } from '@base-ui/react/dialog';
import { IoClose } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import {
Combo,
Separator,
Shortcut,
ShortcutGroup,
ShortcutGroups,
} from '../../../common/components/keyboard-shortcuts/KeyboardShortcuts';
interface HelpOverlayProps {
isOpen: boolean;
onClose: () => void;
}
export default function HelpOverlay({ isOpen, onClose }: HelpOverlayProps) {
return (
<Dialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
>
<Dialog.Portal>
<Dialog.Backdrop className='teleprompter__help' />
<Dialog.Popup className='teleprompter__help-card'>
<div className='teleprompter__help-header'>
<Dialog.Title className='teleprompter__help-title'>Prompter shortcuts</Dialog.Title>
<IconButton variant='subtle-white' size='large' onClick={onClose} aria-label='Close'>
<IoClose />
</IconButton>
</div>
<ShortcutGroups className='teleprompter__help-groups'>
<ShortcutGroup title='Transport'>
<Shortcut label='Start / stop scrolling'>
<Combo keys={['Space']} />
</Shortcut>
<Shortcut label='Slower / faster'>
<Combo keys={['←']} />
<Separator />
<Combo keys={['→']} />
</Shortcut>
<Shortcut label='Larger speed steps'>
<Combo keys={['Shift', '←']} />
<Separator />
<Combo keys={['Shift', '→']} />
</Shortcut>
</ShortcutGroup>
<ShortcutGroup title='Navigation'>
<Shortcut label='Nudge one line'>
<Combo keys={['↑']} />
<Separator />
<Combo keys={['↓']} />
</Shortcut>
<Shortcut label='Jump a screen'>
<Combo keys={['PgUp']} />
<Separator />
<Combo keys={['PgDn']} />
</Shortcut>
<Shortcut label='Jump to previous / next event'>
<Combo keys={['Shift', '↑']} />
<Separator />
<Combo keys={['Shift', '↓']} />
</Shortcut>
<Shortcut label='Jump to top / end'>
<Combo keys={['Home']} />
<Separator />
<Combo keys={['End']} />
</Shortcut>
<Shortcut label='Rewind and stop'>
<Combo keys={['Esc']} />
</Shortcut>
<Shortcut label='Follow the loaded event again'>
<Combo keys={['L']} />
</Shortcut>
</ShortcutGroup>
<ShortcutGroup title='Display'>
<Shortcut label='Font size'>
<Combo keys={['+']} />
<Separator />
<Combo keys={['-']} />
</Shortcut>
<Shortcut label='Reset font size'>
<Combo keys={['0']} />
</Shortcut>
<Shortcut label='Flip horizontally / vertically'>
<Combo keys={['F']} />
<Separator />
<Combo keys={['Shift', 'F']} />
</Shortcut>
<Shortcut label='Show this list'>
<Combo keys={['?']} />
</Shortcut>
</ShortcutGroup>
</ShortcutGroups>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}
@@ -0,0 +1,22 @@
import type { ScriptBlock } from '../teleprompter.types';
interface ScriptBlockProps {
block: ScriptBlock;
registerRef: (id: string, element: HTMLElement | null) => void;
}
export default function ScriptBlockView({ block, registerRef }: ScriptBlockProps) {
'use memo';
return (
<section
className='teleprompter__block'
ref={(element) => registerRef(block.id, element)}
data-loaded={block.isLoaded || undefined}
>
{block.groupTitle && <div className='teleprompter__group'>{block.groupTitle}</div>}
{block.heading && <h2 className='teleprompter__heading'>{block.heading}</h2>}
<p className='teleprompter__body'>{block.text}</p>
</section>
);
}
@@ -0,0 +1,67 @@
import { SPEED_STEP, SPEED_STEP_COARSE } from './teleprompter.scroll';
import type { TeleprompterAction } from './teleprompter.types';
export type TeleprompterKeyEvent = {
code: string;
key: string;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
altKey: boolean;
repeat: boolean;
};
export function resolveTeleprompterAction(event: TeleprompterKeyEvent): TeleprompterAction | null {
if (event.ctrlKey || event.metaKey || event.altKey) {
return null;
}
switch (event.code) {
case 'Space':
return event.repeat ? null : { type: 'togglePlay' };
// Shift is the coarser step on both axes: a bigger speed change sideways,
// a whole event rather than a line vertically.
case 'ArrowDown':
return event.shiftKey ? { type: 'jumpEvent', direction: 1 } : { type: 'nudge', lines: 1 };
case 'ArrowUp':
return event.shiftKey ? { type: 'jumpEvent', direction: -1 } : { type: 'nudge', lines: -1 };
case 'PageDown':
return { type: 'page', direction: 1 };
case 'PageUp':
return { type: 'page', direction: -1 };
case 'ArrowRight':
return { type: 'speed', delta: event.shiftKey ? SPEED_STEP_COARSE : SPEED_STEP };
case 'ArrowLeft':
return { type: 'speed', delta: event.shiftKey ? -SPEED_STEP_COARSE : -SPEED_STEP };
case 'Home':
return { type: 'rewind' };
case 'End':
return { type: 'jumpToEnd' };
case 'Escape':
return { type: 'rewindAndPause' };
}
// Character bindings use key so they work across keyboard layouts.
switch (event.key) {
case '?':
return { type: 'toggleHelp' };
case '+':
case '=':
return { type: 'fontSize', steps: 1 };
case '-':
case '_':
return { type: 'fontSize', steps: -1 };
case '0':
return { type: 'resetFontSize' };
}
const lowerKey = event.key.toLowerCase();
if (lowerKey === 'f') {
return { type: 'flip', axis: event.shiftKey ? 'v' : 'h' };
}
if (lowerKey === 'l') {
return { type: 'reengageFollow' };
}
return null;
}
@@ -0,0 +1,223 @@
import type { CustomFields } from 'ontime-types';
import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import type { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../common/viewUtils';
import { DEFAULT_SPEED, MAX_FONT_SIZE, MAX_SPEED, MIN_FONT_SIZE, MIN_SPEED } from './teleprompter.scroll';
import type { HeadingSource, TeleprompterOptions } from './teleprompter.types';
const headingOptions: { value: HeadingSource; label: string }[] = [
{ value: 'title', label: 'Title' },
{ value: 'cue', label: 'Cue' },
{ value: 'both', label: 'Cue and title' },
{ value: 'none', label: 'None' },
];
const headingSources = headingOptions.map((option) => option.value);
export const defaults = {
script: 'none',
heading: 'title' as HeadingSource,
hideEmpty: true,
showGroups: true,
speed: DEFAULT_SPEED,
followLoaded: true,
fontSize: 52,
lineHeight: 1.3,
textWidth: 80,
readingLine: true,
readingLinePos: 25,
flipH: false,
flipV: false,
};
const bounds = {
speed: [MIN_SPEED, MAX_SPEED],
fontSize: [MIN_FONT_SIZE, MAX_FONT_SIZE],
lineHeight: [1, 4],
textWidth: [20, 100],
readingLinePos: [0, 100],
} as const;
export const getTeleprompterOptions = (customFields: CustomFields): ViewOption[] => {
const scriptOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'note', label: 'Note' },
{ value: 'title', label: 'Title' },
]);
return [
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'script',
title: 'Script',
description: 'Select the data source which holds the script to read',
type: 'option',
values: scriptOptions,
defaultValue: defaults.script,
},
{
id: 'heading',
title: 'Segment heading',
description: 'What to show above each segment of the script',
type: 'option',
values: headingOptions,
defaultValue: defaults.heading,
},
],
},
{
title: OptionTitle.BehaviourOptions,
collapsible: true,
options: [
{
id: 'speed',
title: 'Speed',
description: `Scroll speed in lines per minute (${MIN_SPEED}-${MAX_SPEED}). Adjustable live with the arrow keys`,
type: 'number',
defaultValue: defaults.speed,
},
{
id: 'followLoaded',
title: 'Follow loaded event',
description: 'Scroll to the segment of the loaded event. Scrolling by hand releases the follow',
type: 'boolean',
defaultValue: defaults.followLoaded,
},
],
},
{
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'hideEmpty',
title: 'Hide events without a script',
description: 'Prevents showing headings for events which have no script text',
type: 'boolean',
defaultValue: defaults.hideEmpty,
},
{
id: 'showGroups',
title: 'Show group names',
description: 'Shows the group name when the script moves into a new group',
type: 'boolean',
defaultValue: defaults.showGroups,
},
],
},
{
title: OptionTitle.StyleOverride,
collapsible: true,
options: [
{
id: 'fontSize',
title: 'Font size',
description: 'Base font size in pixels. Adjustable live with the + and - keys',
type: 'number',
defaultValue: defaults.fontSize,
},
{
id: 'lineHeight',
title: 'Line height',
description: 'Spacing between lines, as a multiple of the font size',
type: 'number',
defaultValue: defaults.lineHeight,
},
{
id: 'textWidth',
title: 'Text width',
description: 'Width of the text column as a percentage of the screen. Narrower means less eye movement',
type: 'number',
defaultValue: defaults.textWidth,
},
{
id: 'readingLine',
title: 'Reading line',
description: 'Shows a marker beside the line which should be read',
type: 'boolean',
defaultValue: defaults.readingLine,
},
{
id: 'readingLinePos',
title: 'Reading line position',
description: 'Position of the reading line as a percentage from the top of the screen',
type: 'number',
defaultValue: defaults.readingLinePos,
},
{
id: 'flipH',
title: 'Flip horizontally',
description:
'Mirrors the view horizontally, which is what a beam splitter rig needs. Toggled live with F. Flip Screen in the navigation menu flips both axes at once, which is a rotation rather than a mirror',
type: 'boolean',
defaultValue: defaults.flipH,
},
{
id: 'flipV',
title: 'Flip vertically',
description: 'Mirrors the view vertically. Note this also moves the reading line. Toggled live with Shift+F',
type: 'boolean',
defaultValue: defaults.flipV,
},
],
},
];
};
function toNumber(value: string | null, [min, max]: readonly [number, number], fallback: number): number {
if (value === null || value === '') return fallback;
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
}
function toBoolean(value: string | null, fallback: boolean): boolean {
return value === null ? fallback : isStringBoolean(value);
}
function toEnum<T extends string>(value: string | null, allowed: readonly T[], fallback: T): T {
return allowed.includes(value as T) ? (value as T) : fallback;
}
export function getOptionsFromParams(
searchParams: URLSearchParams,
defaultValues?: URLSearchParams,
): TeleprompterOptions {
const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
return {
scriptSource: getValue('script') ?? defaults.script,
heading: toEnum(getValue('heading'), headingSources, defaults.heading),
hideEmpty: toBoolean(getValue('hideEmpty'), defaults.hideEmpty),
showGroups: toBoolean(getValue('showGroups'), defaults.showGroups),
speed: toNumber(getValue('speed'), bounds.speed, defaults.speed),
followLoaded: toBoolean(getValue('followLoaded'), defaults.followLoaded),
fontSize: toNumber(getValue('fontSize'), bounds.fontSize, defaults.fontSize),
lineHeight: toNumber(getValue('lineHeight'), bounds.lineHeight, defaults.lineHeight),
textWidth: toNumber(getValue('textWidth'), bounds.textWidth, defaults.textWidth),
readingLine: toBoolean(getValue('readingLine'), defaults.readingLine),
readingLinePos: toNumber(getValue('readingLinePos'), bounds.readingLinePos, defaults.readingLinePos),
flipH: toBoolean(getValue('flipH'), defaults.flipH),
flipV: toBoolean(getValue('flipV'), defaults.flipV),
};
}
export function useTeleprompterOptions(): TeleprompterOptions {
const [searchParams] = useSearchParams();
const maybePreset = use(PresetContext);
return useMemo(() => {
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
return getOptionsFromParams(searchParams, defaultValues);
}, [maybePreset, searchParams]);
}
@@ -0,0 +1,135 @@
export const MIN_SPEED = 1;
export const MAX_SPEED = 40;
export const DEFAULT_SPEED = 14;
export const SPEED_STEP = 1;
export const SPEED_STEP_COARSE = 5;
export const MIN_FONT_SIZE = 12;
export const MAX_FONT_SIZE = 400;
const FONT_SIZE_STEP_RATIO = 1.1;
export function stepFontSize(current: number, steps: number): number {
return clamp(Math.round(current * FONT_SIZE_STEP_RATIO ** steps), MIN_FONT_SIZE, MAX_FONT_SIZE);
}
export const MAX_FRAME_DELTA_MS = 100;
const CATCH_UP_RATE = 8;
const CATCH_UP_EPSILON = 0.5;
export function clamp(value: number, min: number, max: number): number {
if (Number.isNaN(value)) return min;
return Math.min(Math.max(value, min), max);
}
export function clampSpeed(value: number): number {
return clamp(value, MIN_SPEED, MAX_SPEED);
}
export function linesPerMinuteToPxPerSecond(linesPerMinute: number, lineHeightPx: number): number {
return (linesPerMinute / 60) * lineHeightPx;
}
export function frameDeltaSeconds(deltaMs: number): number {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return 0;
return Math.min(deltaMs, MAX_FRAME_DELTA_MS) / 1000;
}
export function advance(
position: number,
pxPerSecond: number,
deltaSeconds: number,
maxScroll: number,
): { position: number; atEnd: boolean } {
const next = position + pxPerSecond * deltaSeconds;
return { position: next, atEnd: maxScroll > 0 && next >= maxScroll };
}
export function easeCatchUp(current: number, target: number, deltaSeconds: number): number {
if (deltaSeconds <= 0) return current;
const next = target + (current - target) * Math.exp(-CATCH_UP_RATE * deltaSeconds);
return Math.abs(next - target) < CATCH_UP_EPSILON ? target : next;
}
/** Where a block sits inside the scrolled content, in layout pixels, ordered top to bottom. */
export type BlockGeometry = { id: string; top: number; height: number };
/**
* The read position expressed as a place in the script rather than an offset
* into the document.
*
* A rundown is edited while it is being read, and an edit above the reader
* moves every pixel below it. Professional prompters cue a story plus an
* offset into it for exactly this reason: the text under the reading line is
* the position, the scroll offset is only how it is currently drawn.
*/
export type ScrollAnchor = { blockId: string; offset: number };
/**
* Index of the block the read point falls in, clamped to the ends of the script.
*
* Scans rather than bisects: this runs once a frame over a show's worth of
* events, and a scan makes no assumption the caller has to keep true.
*/
export function indexAtReadPoint(readPoint: number, blocks: BlockGeometry[]): number {
if (blocks.length === 0) return -1;
// starts on the first block, which is where a read point above the script lands
let index = 0;
for (let i = 1; i < blocks.length; i += 1) {
if (blocks[i].top > readPoint) break;
index = i;
}
return index;
}
export function anchorAtReadPoint(readPoint: number, blocks: BlockGeometry[]): ScrollAnchor | null {
const index = indexAtReadPoint(readPoint, blocks);
if (index === -1) return null;
return { blockId: blocks[index].id, offset: readPoint - blocks[index].top };
}
/**
* The read point which puts an anchored place in the script back under the
* reading line, or null when it cannot be found at all.
*/
export function readPointForAnchor(
anchor: ScrollAnchor,
blocks: BlockGeometry[],
previousOrder: string[],
): number | null {
const match = blocks.find((block) => block.id === anchor.blockId);
if (match) {
// The block may have been edited shorter than the offset into it.
return match.top + Math.min(anchor.offset, match.height);
}
// The anchored event was deleted mid-read. Land on the end of the nearest
// event which preceded it and survives, which is where the deleted text
// used to begin, rather than wherever its pixels now happen to point.
const previousIndex = previousOrder.indexOf(anchor.blockId);
for (let i = previousIndex - 1; i >= 0; i -= 1) {
const survivor = blocks.find((block) => block.id === previousOrder[i]);
if (survivor) return survivor.top + survivor.height;
}
return null;
}
/** How far, in lines, the reader may move the script themselves before it counts as taking over. */
export const FOLLOW_BREAK_LINES = 1.5;
/**
* Distinguishes a deliberate scroll away from the read position from momentum
* or a stray touch.
*
* Takes what the reader moved rather than where the script ended up: while
* following eases towards a newly loaded event, and while playback carries the
* script along, the distance to the target is the prompter's own doing.
*/
export function hasBrokenFollow(readerDriftPx: number, lineHeightPx: number): boolean {
if (lineHeightPx <= 0) return false;
return Math.abs(readerDriftPx) > lineHeightPx * FOLLOW_BREAK_LINES;
}
@@ -0,0 +1,54 @@
import type { MaybeString } from 'ontime-types';
export type HeadingSource = 'none' | 'title' | 'cue' | 'both';
export type ScriptBlock = {
id: string;
heading: string;
text: string;
groupTitle: MaybeString;
isLoaded: boolean;
};
export type TeleprompterOptions = {
scriptSource: string;
heading: HeadingSource;
hideEmpty: boolean;
showGroups: boolean;
/** lines per minute */
speed: number;
followLoaded: boolean;
fontSize: number;
lineHeight: number;
textWidth: number;
readingLine: boolean;
readingLinePos: number;
flipH: boolean;
flipV: boolean;
};
export type TeleprompterAction =
| { type: 'togglePlay' }
| { type: 'nudge'; lines: number }
| { type: 'page'; direction: 1 | -1 }
| { type: 'jumpEvent'; direction: 1 | -1 }
| { type: 'speed'; delta: number }
| { type: 'rewind' }
| { type: 'rewindAndPause' }
| { type: 'jumpToEnd' }
| { type: 'flip'; axis: 'h' | 'v' }
| { type: 'fontSize'; steps: number }
| { type: 'resetFontSize' }
| { type: 'reengageFollow' }
| { type: 'toggleHelp' };
export type TeleprompterController = {
togglePlay: () => void;
nudge: (lines: number) => void;
page: (direction: 1 | -1) => void;
jumpEvent: (direction: 1 | -1) => void;
changeSpeed: (delta: number) => void;
rewind: (alsoPause?: boolean) => void;
jumpToEnd: () => void;
reengageFollow: () => void;
};
@@ -0,0 +1,80 @@
import { type CustomFields, isOntimeEvent, isOntimeGroup, type MaybeString, type Rundown } from 'ontime-types';
import type { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { getPropertyValue } from '../common/viewUtils';
import type { HeadingSource, ScriptBlock, TeleprompterOptions } from './teleprompter.types';
type BuildScriptOptions = Pick<TeleprompterOptions, 'scriptSource' | 'heading' | 'hideEmpty' | 'showGroups'>;
function makeHeading(source: HeadingSource, cue: string, title: string): string {
switch (source) {
case 'cue':
return cue;
case 'title':
return title;
case 'both':
return [cue, title].filter(Boolean).join(' · ');
case 'none':
return '';
}
}
function isReadableSource(scriptSource: string, customFields: CustomFields): boolean {
if (!scriptSource.startsWith('custom-')) {
return true;
}
const key = scriptSource.slice('custom-'.length);
return customFields[key]?.type === 'text';
}
/** Builds the continuous script in rundown order. */
export function buildScript(
rundown: Rundown,
rundownMetadata: RundownMetadataObject,
customFields: CustomFields,
options: BuildScriptOptions,
): ScriptBlock[] {
const { scriptSource, heading, hideEmpty, showGroups } = options;
if (scriptSource === 'none' || !isReadableSource(scriptSource, customFields)) {
return [];
}
const blocks: ScriptBlock[] = [];
let lastGroupId: MaybeString = null;
for (const id of rundown.flatOrder) {
const entry = rundown.entries[id];
if (!isOntimeEvent(entry) || entry.skip) {
continue;
}
const metadata = rundownMetadata[id];
const text = getPropertyValue(entry, scriptSource, rundown.entries)?.trim() ?? '';
if (hideEmpty && !text) {
continue;
}
const groupId = metadata?.groupId ?? null;
let groupTitle: MaybeString = null;
if (showGroups && groupId && groupId !== lastGroupId) {
const group = rundown.entries[groupId];
groupTitle = isOntimeGroup(group) ? group.title : null;
}
lastGroupId = groupId;
blocks.push({
id,
heading: makeHeading(heading, entry.cue, entry.title),
text,
groupTitle,
isLoaded: Boolean(metadata?.isLoaded),
});
}
return blocks;
}
export function composeFlip(flipH: boolean, flipV: boolean, isMirrored: boolean): { flipH: boolean; flipV: boolean } {
return { flipH: flipH !== isMirrored, flipV: flipV !== isMirrored };
}
@@ -0,0 +1,50 @@
import { use, useEffect } from 'react';
import { useSearchParams } from 'react-router';
import { PresetContext } from '../../common/context/PresetContext';
import { defaults } from './teleprompter.options';
const SETTLE_MS = 400;
interface LiveParams {
speed: number;
fontSize: number;
flipH: boolean;
flipV: boolean;
}
/** Keeps keyboard and overlay changes shareable through the URL. */
export function useSyncTeleprompterParams({ speed, fontSize, flipH, flipV }: LiveParams) {
const [, setSearchParams] = useSearchParams();
const isPreset = Boolean(use(PresetContext));
useEffect(() => {
if (isPreset) return;
const timeout = setTimeout(() => {
const values = {
speed: speed === defaults.speed ? null : String(speed),
fontSize: fontSize === defaults.fontSize ? null : String(fontSize),
flipH: flipH === defaults.flipH ? null : String(flipH),
flipV: flipV === defaults.flipV ? null : String(flipV),
};
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
for (const [key, value] of Object.entries(values)) {
if (value === null) {
next.delete(key);
} else {
next.set(key, value);
}
}
return next;
},
{ replace: true },
);
}, SETTLE_MS);
return () => clearTimeout(timeout);
}, [flipH, flipV, fontSize, isPreset, setSearchParams, speed]);
}
@@ -0,0 +1,76 @@
import { useEffect, useRef } from 'react';
import { useViewParamsEditorStore } from '../../common/components/view-params-editor/viewParamsEditor.store';
import { resolveTeleprompterAction } from './teleprompter.keymap';
import type { TeleprompterAction, TeleprompterController } from './teleprompter.types';
interface UseTeleprompterControlsArgs {
controller: TeleprompterController;
isHelpOpen: boolean;
onFlip: (axis: 'h' | 'v') => void;
onFontSize: (steps: number) => void;
onResetFontSize: () => void;
onToggleHelp: () => void;
}
const ignoredTags = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
const argsRef = useRef(args);
useEffect(() => {
argsRef.current = args;
});
useEffect(() => {
function applyAction(action: TeleprompterAction) {
const { controller, onFlip, onFontSize, onResetFontSize, onToggleHelp } = argsRef.current;
switch (action.type) {
case 'togglePlay':
return controller.togglePlay();
case 'nudge':
return controller.nudge(action.lines);
case 'page':
return controller.page(action.direction);
case 'jumpEvent':
return controller.jumpEvent(action.direction);
case 'speed':
return controller.changeSpeed(action.delta);
case 'rewind':
return controller.rewind();
case 'rewindAndPause':
return controller.rewind(true);
case 'jumpToEnd':
return controller.jumpToEnd();
case 'reengageFollow':
return controller.reengageFollow();
case 'flip':
return onFlip(action.axis);
case 'fontSize':
return onFontSize(action.steps);
case 'resetFontSize':
return onResetFontSize();
case 'toggleHelp':
return onToggleHelp();
}
}
function handleKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement | null;
if (target && (ignoredTags.has(target.tagName) || target.isContentEditable)) {
return;
}
if (useViewParamsEditorStore.getState().isOpen || argsRef.current.isHelpOpen) {
return;
}
const action = resolveTeleprompterAction(event);
if (!action) return;
event.preventDefault();
applyAction(action);
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
}
@@ -0,0 +1,26 @@
import type { CustomFields, Rundown } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import type { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { type ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
export interface TeleprompterData {
rundown: Rundown;
rundownMetadata: RundownMetadataObject;
customFields: CustomFields;
}
export function useTeleprompterData(): ViewData<TeleprompterData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
return {
data: {
rundown,
rundownMetadata,
customFields,
},
status: aggregateQueryStatus([rundownStatus, customFieldStatus]),
};
}
@@ -0,0 +1,435 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
advance,
anchorAtReadPoint,
type BlockGeometry,
clamp,
clampSpeed,
easeCatchUp,
frameDeltaSeconds,
hasBrokenFollow,
indexAtReadPoint,
linesPerMinuteToPxPerSecond,
readPointForAnchor,
type ScrollAnchor,
} from './teleprompter.scroll';
import type { ScriptBlock, TeleprompterController } from './teleprompter.types';
const PAGE_FRACTION = 0.85;
const EXTERNAL_SCROLL_EPSILON = 2;
/** Below this the correction would be invisible and only add jitter. */
const ANCHOR_CORRECTION_EPSILON = 1;
/**
* Uses layout coordinates because client rects include the teleprompter's CSS
* transform while scrollTop does not.
*/
function getLayoutTop(element: HTMLElement): number {
let top = 0;
let current: HTMLElement | null = element;
while (current) {
top += current.offsetTop;
current = current.offsetParent instanceof HTMLElement ? current.offsetParent : null;
}
return top;
}
interface UseTeleprompterScrollArgs {
initialSpeed: number;
followLoaded: boolean;
selectedEventId: string | null;
readingLinePos: number;
blocks: ScriptBlock[];
}
/**
* Owns the scroll position of the teleprompter.
*
* The animation frame is the only writer of scrollTop. Controls update refs so
* smooth scrolling and playback cannot compete for the DOM position.
*/
export function useTeleprompterScroll({
initialSpeed,
followLoaded,
selectedEventId,
readingLinePos,
blocks,
}: UseTeleprompterScrollArgs) {
const scrollerRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const blockRefs = useRef(new Map<string, HTMLElement>());
const [isScrollerMounted, setIsScrollerMounted] = useState(false);
const attachScroller = useCallback((element: HTMLDivElement | null) => {
scrollerRef.current = element;
setIsScrollerMounted(Boolean(element && contentRef.current));
}, []);
const attachContent = useCallback((element: HTMLDivElement | null) => {
contentRef.current = element;
setIsScrollerMounted(Boolean(element && scrollerRef.current));
}, []);
// authoritative, sub-pixel scroll position
const posRef = useRef(0);
const lastTsRef = useRef(0);
const runningRef = useRef(false);
const speedRef = useRef(initialSpeed);
const lineHeightRef = useRef(0);
const maxScrollRef = useRef(0);
const catchUpTargetRef = useRef<number | null>(null);
const pendingDeltaRef = useRef(0);
// where following last put (or is easing towards putting) the reader
const followTargetRef = useRef(0);
// how far the reader has moved the script themselves since following last placed it
const readerDriftRef = useRef(0);
// distance from the top of the viewport to the reading line
const readingOffsetRef = useRef(0);
const readingLinePosRef = useRef(readingLinePos);
const selectedEventIdRef = useRef(selectedEventId);
// the script's layout as of the last measure, and the reader's place in it
const geometryRef = useRef<BlockGeometry[]>([]);
const anchorRef = useRef<ScrollAnchor | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [speed, setSpeed] = useState(initialSpeed);
// mirrors the operator view's lockAutoScroll: true once the reader has taken
// the scroll over by hand, false while following is doing the driving
const [autoScrollLocked, setAutoScrollLocked] = useState(false);
const [atEnd, setAtEnd] = useState(false);
const isFollowingRef = useRef(false);
useEffect(() => {
isFollowingRef.current = followLoaded && !autoScrollLocked;
}, [followLoaded, autoScrollLocked]);
/**
* One rule for every input, wheel and keyboard alike: once the reader has
* moved the script far enough themselves, they are driving.
*
* Accumulates, so a gesture the browser spreads over many frames adds up to
* the move it was, and so does a slow drag. Scrolling back where you came
* from cancels out, which is what keeps momentum and a stray touch from
* taking the scroll over.
*/
const addReaderDrift = useCallback((delta: number) => {
if (!isFollowingRef.current) return;
readerDriftRef.current += delta;
if (hasBrokenFollow(readerDriftRef.current, lineHeightRef.current)) {
setAutoScrollLocked(true);
}
}, []);
const tick = useCallback(
(timestamp: number) => {
const el = scrollerRef.current;
if (!el) return;
// Adopt wheel, touch, scrollbar, browser-clamp and find-in-page changes.
// Whatever the position is that the frame loop did not put there is the
// reader's own doing, which makes this the one place a scroll by hand can
// be measured, however the browser chose to deliver it.
const external = el.scrollTop - posRef.current;
if (Math.abs(external) > EXTERNAL_SCROLL_EPSILON) {
addReaderDrift(external);
posRef.current = el.scrollTop;
catchUpTargetRef.current = null;
}
const deltaSeconds = frameDeltaSeconds(timestamp - lastTsRef.current);
lastTsRef.current = timestamp;
let next = posRef.current;
if (pendingDeltaRef.current !== 0) {
next += pendingDeltaRef.current;
pendingDeltaRef.current = 0;
catchUpTargetRef.current = null;
}
if (catchUpTargetRef.current !== null) {
next = easeCatchUp(next, catchUpTargetRef.current, deltaSeconds);
if (next === catchUpTargetRef.current) {
catchUpTargetRef.current = null;
}
} else if (runningRef.current) {
const pxPerSecond = linesPerMinuteToPxPerSecond(speedRef.current, lineHeightRef.current);
const result = advance(next, pxPerSecond, deltaSeconds, maxScrollRef.current);
next = result.position;
if (result.atEnd) {
runningRef.current = false;
setIsRunning(false);
setAtEnd(true);
}
}
const clamped = clamp(next, 0, maxScrollRef.current);
if (clamped !== posRef.current) {
posRef.current = clamped;
el.scrollTop = clamped;
}
// Remember the reader's place in the script, not just in the document, so
// the next measure can put them back if the rundown changed underneath
// them. Reads no layout: the geometry is the one taken at that measure.
anchorRef.current = anchorAtReadPoint(clamped + readingOffsetRef.current, geometryRef.current);
},
[addReaderDrift],
);
useEffect(() => {
lastTsRef.current = performance.now();
let frame = requestAnimationFrame(function loop(timestamp) {
tick(timestamp);
frame = requestAnimationFrame(loop);
});
return () => cancelAnimationFrame(frame);
}, [tick]);
/** Where following would put the reader for a block, against the last measure. */
const scrollTargetFor = useCallback((blockId: string): number | null => {
const block = geometryRef.current.find((entry) => entry.id === blockId);
if (!block) return null;
return clamp(block.top - readingOffsetRef.current, 0, maxScrollRef.current);
}, []);
const measure = useCallback(() => {
const scroller = scrollerRef.current;
const content = contentRef.current;
if (!scroller || !content) return;
maxScrollRef.current = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
readingOffsetRef.current = (scroller.clientHeight * readingLinePosRef.current) / 100;
const computed = getComputedStyle(content);
const parsedLineHeight = Number.parseFloat(computed.lineHeight);
if (Number.isFinite(parsedLineHeight) && parsedLineHeight > 0) {
lineHeightRef.current = parsedLineHeight;
} else {
const parsedFontSize = Number.parseFloat(computed.fontSize);
lineHeightRef.current = Number.isFinite(parsedFontSize) ? parsedFontSize * 1.2 : 0;
}
const wasEasingToLoadedEvent = catchUpTargetRef.current === followTargetRef.current;
const previous = geometryRef.current;
const scrollerTop = getLayoutTop(scroller);
const geometry: BlockGeometry[] = [];
blockRefs.current.forEach((element, id) => {
geometry.push({ id, top: getLayoutTop(element) - scrollerTop, height: element.offsetHeight });
});
// registration follows mounting rather than rundown order, so trust layout
geometry.sort((a, b) => a.top - b.top);
geometryRef.current = geometry;
// Put the reader back on the words they were on. Nothing to restore on the
// first measure, when there is no earlier document to have moved.
const anchor = anchorRef.current;
if (anchor && previous.length > 0) {
const readPoint = readPointForAnchor(
anchor,
geometry,
previous.map((block) => block.id),
);
if (readPoint !== null) {
const target = clamp(readPoint - readingOffsetRef.current, 0, maxScrollRef.current);
const delta = target - posRef.current;
if (Math.abs(delta) > ANCHOR_CORRECTION_EPSILON) {
posRef.current = target;
scroller.scrollTop = target;
if (catchUpTargetRef.current !== null) {
catchUpTargetRef.current = clamp(catchUpTargetRef.current + delta, 0, maxScrollRef.current);
}
}
}
}
// The follow target is a position in a document which may have just moved,
// so take it from the new geometry rather than ageing the old value.
const selectedId = selectedEventIdRef.current;
if (selectedId !== null) {
const followTarget = scrollTargetFor(selectedId);
if (followTarget !== null) {
followTargetRef.current = followTarget;
}
}
// Shifting an in-flight ease by the correction is only an estimate. When it
// was heading for the loaded event we know better: send it to where that
// event is now, so following still lands exactly on the reading line.
if (wasEasingToLoadedEvent) {
catchUpTargetRef.current = followTargetRef.current;
}
}, [scrollTargetFor]);
useEffect(() => {
speedRef.current = speed;
}, [speed]);
const [speedFromOption, setSpeedFromOption] = useState(initialSpeed);
// Keep the live speed in sync with view-param and remote redirect changes.
if (speedFromOption !== initialSpeed) {
setSpeedFromOption(initialSpeed);
setSpeed(initialSpeed);
}
useEffect(() => {
measure();
const scroller = scrollerRef.current;
const content = contentRef.current;
if (!scroller || !content) return;
const observer = new ResizeObserver(() => measure());
observer.observe(scroller);
observer.observe(content);
return () => observer.disconnect();
}, [measure, isScrollerMounted]);
useEffect(() => {
let cancelled = false;
void document.fonts?.ready.then(() => {
if (!cancelled) measure();
});
return () => {
cancelled = true;
};
}, [measure]);
useEffect(() => {
readingLinePosRef.current = readingLinePos;
measure();
}, [readingLinePos, measure]);
/**
* A resize observer sees the document get taller or shorter, but not a
* reorder or a rewrite which happens to leave the height alone, and both
* move the reader's place in the script.
*/
const contentSignature = blocks.map((block) => `${block.id}:${block.text.length}`).join();
useEffect(() => {
measure();
}, [contentSignature, measure]);
useEffect(() => {
const onVisibilityChange = () => {
lastTsRef.current = performance.now();
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
}, []);
useEffect(() => {
selectedEventIdRef.current = selectedEventId;
}, [selectedEventId]);
// Avoid re-following when a new blocks array contains the same selected event.
const hasSelectedBlock = selectedEventId !== null && blocks.some((block) => block.id === selectedEventId);
useEffect(() => {
if (!followLoaded || autoScrollLocked || !selectedEventId) return;
const target = scrollTargetFor(selectedEventId);
if (target === null) return;
followTargetRef.current = target;
catchUpTargetRef.current = target;
readerDriftRef.current = 0;
setAtEnd(false);
}, [selectedEventId, followLoaded, autoScrollLocked, readingLinePos, hasSelectedBlock, scrollTargetFor]);
const registerBlock = useCallback((id: string, element: HTMLElement | null) => {
if (element) {
blockRefs.current.set(id, element);
} else {
blockRefs.current.delete(id);
}
}, []);
const controller: TeleprompterController = useMemo(() => {
const play = () => {
if (maxScrollRef.current > 0 && posRef.current >= maxScrollRef.current) {
return;
}
runningRef.current = true;
setIsRunning(true);
setAtEnd(false);
};
const pause = () => {
runningRef.current = false;
setIsRunning(false);
};
/**
* Where the scroll is headed rather than where it currently sits, so
* pressing a step key again before the ease settles moves on by another
* step instead of re-aiming at the one already in flight.
*/
const destination = () => catchUpTargetRef.current ?? posRef.current;
/** Eases to a position the reader asked for, by hand. */
const goTo = (position: number) => {
const target = clamp(position, 0, maxScrollRef.current);
addReaderDrift(target - destination());
catchUpTargetRef.current = target;
setAtEnd(false);
};
return {
togglePlay: () => (runningRef.current ? pause() : play()),
nudge: (lines: number) => {
const distance = lines * lineHeightRef.current;
pendingDeltaRef.current += distance;
addReaderDrift(distance);
setAtEnd(false);
},
page: (direction: 1 | -1) => {
const scroller = scrollerRef.current;
if (!scroller) return;
goTo(destination() + scroller.clientHeight * PAGE_FRACTION * direction);
},
jumpEvent: (direction: 1 | -1) => {
const geometry = geometryRef.current;
const current = indexAtReadPoint(destination() + readingOffsetRef.current, geometry);
if (current === -1) return;
const next = clamp(current + direction, 0, geometry.length - 1);
goTo(geometry[next].top - readingOffsetRef.current);
},
changeSpeed: (delta: number) => setSpeed((current) => clampSpeed(current + delta)),
rewind: (alsoPause = false) => {
goTo(0);
if (alsoPause) pause();
},
jumpToEnd: () => {
addReaderDrift(maxScrollRef.current - destination());
catchUpTargetRef.current = maxScrollRef.current;
if (maxScrollRef.current > 0) {
runningRef.current = false;
setIsRunning(false);
setAtEnd(true);
}
},
reengageFollow: () => {
readerDriftRef.current = 0;
setAutoScrollLocked(false);
},
};
}, [addReaderDrift]);
return {
scrollerRef: attachScroller,
contentRef: attachContent,
registerBlock,
controller,
isRunning,
speed,
// folds followLoaded in, so callers get one ready-to-use signal instead of
// a runtime flag they must remember to AND with the option themselves
canReengageFollow: followLoaded && autoScrollLocked,
atEnd,
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.12.0",
"version": "4.13.0-beta.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -151,6 +151,7 @@ function makeViewMenu(clientUrl) {
makeItemOpenInBrowser('Editor', `${clientUrl}/editor`),
makeItemOpenInBrowser('Cuesheet', `${clientUrl}/cuesheet`),
makeItemOpenInBrowser('Operator', `${clientUrl}/op`),
makeItemOpenInBrowser('Teleprompter', `${clientUrl}/teleprompter`),
{ type: 'separator' },
makeItemOpenInBrowser('Timer', `${clientUrl}/timer`),
makeItemOpenInBrowser('Backstage', `${clientUrl}/backstage`),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.12.0",
"version": "4.13.0-beta.1",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.12.0",
"version": "4.13.0-beta.1",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
+13 -6
View File
@@ -9,45 +9,52 @@ test.describe('test view navigation feature', () => {
test('Timeline', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Timeline' }).click();
page.locator('data-testid=timeline-view');
await expect(page.getByTestId('timeline-view')).toBeVisible();
await expect(page).toHaveURL('/timeline');
});
test('Backstage', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Backstage' }).click();
page.locator('data-testid=backstage-view');
await expect(page.getByTestId('backstage-view')).toBeVisible();
await expect(page).toHaveURL('/backstage');
});
test('Studio Clock', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Studio Clock' }).click();
page.locator('data-testid=studio-view');
await expect(page.getByTestId('studio-view')).toBeVisible();
await expect(page).toHaveURL('/studio');
});
test('Countdown', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Countdown' }).click();
page.locator('data-testid=countdown-view');
await expect(page.getByTestId('countdown-view')).toBeVisible();
await expect(page).toHaveURL('/countdown');
});
test('Project Info', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Project Info' }).click();
page.locator('data-testid=project-view');
await expect(page.getByTestId('project-view')).toBeVisible();
await expect(page).toHaveURL('/info');
});
test('Timer', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Timer', exact: true }).click();
page.locator('data-testid=timer-view');
await expect(page.getByTestId('timer-view')).toBeVisible();
await expect(page).toHaveURL('/timer');
});
test('Teleprompter', async ({ page }) => {
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Teleprompter' }).click();
await expect(page.getByTestId('teleprompter-view')).toBeVisible();
await expect(page).toHaveURL('/teleprompter');
});
/**
* The shortcut has to read the current open state, not the one captured when it was
* registered, otherwise it only ever opens the menu.
@@ -38,3 +38,25 @@ test('View params keeps the values of collapsed sections', async ({ page }) => {
await expect(page).toHaveURL(/.*hideClock=true/);
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
});
test('View params configures teleprompter view', async ({ page, request }) => {
const response = await request.post('/data/db/demo');
expect(response.ok()).toBe(true);
await page.goto('/teleprompter?script=note');
const readingMarker = page.locator('.teleprompter__reading-marker');
await expect(readingMarker).toBeVisible();
await page.mouse.move(Math.random() * 100, Math.random() * 100);
await page.getByTestId('navigation__toggle-settings').click();
const readingLineSwitch = page.locator('label:has(input[name="readingLine"]) [role="switch"]');
await expect(readingLineSwitch).toHaveAttribute('aria-checked', 'true');
await readingLineSwitch.click();
await page.getByTestId('apply-view-params').click();
await expect(page).toHaveURL(/.*readingLine=false/);
await expect(readingMarker).toHaveCount(0);
});
+142
View File
@@ -0,0 +1,142 @@
import { expect, type Page, test } from '@playwright/test';
/** The heading of the event the reading line is currently over. */
function eventUnderReadingLine(page: Page) {
return page.evaluate(() => {
const line = document.querySelector('.teleprompter__reading-line')?.getBoundingClientRect();
if (!line) throw new Error('Reading line not found');
const element = document.elementFromPoint(window.innerWidth / 2, line.top + line.height / 2);
return element?.closest('.teleprompter__block')?.querySelector('.teleprompter__heading')?.textContent ?? null;
});
}
test('teleprompter renders and responds to its primary controls', async ({ page, request }) => {
// Earlier feature specs edit the loaded rundown. Restore the real demo project
// instead of manufacturing test-only events: its notes are the script fixture.
const response = await request.post('/data/db/demo');
expect(response.ok()).toBe(true);
const loadResponse = await request.get('/api/load/index/5');
expect(loadResponse.ok()).toBe(true);
await page.goto('/teleprompter?script=note&flipV=true');
const view = page.getByTestId('teleprompter-view');
const scroller = page.getByTestId('teleprompter-scroller');
const speed = page.getByTestId('teleprompter-speed');
await expect(view).toBeVisible();
await expect(scroller).toBeVisible();
await expect(page.getByText('Music plays, holding slide on screens')).toBeVisible();
await expect(speed).toContainText('14');
await expect(view).toHaveCSS('transform', /^matrix\(1, 0, 0, -1/);
// Following uses layout coordinates, which must remain stable when the view's
// visual coordinates are inverted by a vertical transform.
const loadedBlock = page.locator('.teleprompter__block[data-loaded]');
await expect(loadedBlock).toBeAttached();
const expectedScrollTop = await loadedBlock.evaluate((element) => {
const scroller = element.closest('.teleprompter')?.querySelector<HTMLElement>('.teleprompter__scroller');
if (!scroller) throw new Error('Teleprompter scroller not found');
return Math.max(0, (element as HTMLElement).offsetTop - scroller.clientHeight * 0.25);
});
await expect
.poll(async () => Math.abs((await scroller.evaluate((element) => element.scrollTop)) - expectedScrollTop))
.toBeLessThan(2);
await page.goto('/teleprompter?script=note');
await page.keyboard.press('Space');
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
await page.keyboard.press('Space');
await page.keyboard.press('ArrowRight');
await page.keyboard.press('f');
await expect(speed).toContainText('15');
await expect(page).toHaveURL(/speed=15/);
await expect(page).toHaveURL(/flipH=true/);
await expect(view).toHaveCSS('transform', /^matrix\(-1/);
});
test('an edit above the reader leaves the same text under the reading line', async ({ page, request }) => {
const response = await request.post('/data/db/demo');
expect(response.ok()).toBe(true);
const loadResponse = await request.get('/api/load/index/5');
expect(loadResponse.ok()).toBe(true);
// following moves the reader for its own reasons; this is about the document
// changing underneath a position the reader chose
await page.goto('/teleprompter?script=note&followLoaded=false');
const scroller = page.getByTestId('teleprompter-scroller');
await expect(scroller).toBeVisible();
// park the reading line inside a block rather than at a fraction of the
// document, whose tail is a screen of padding below the last event
await scroller.evaluate((element) => {
const blocks = element.querySelectorAll<HTMLElement>('.teleprompter__block');
const target = blocks[Math.floor(blocks.length / 2)];
element.scrollTop = target.offsetTop + 10 - element.clientHeight * 0.25;
});
const before = await eventUnderReadingLine(page);
expect(before).not.toBeNull();
const scrollBefore = await scroller.evaluate((element) => element.scrollTop);
// grow the first event's script, which sits above wherever we scrolled to
const edit = await request.put('/data/rundowns/default/entry', {
data: { id: '9bf60f', note: `Music plays, holding slide on screens\n${'Another line of script. '.repeat(120)}` },
});
expect(edit.ok()).toBe(true);
// the document grew, so holding position means the offset had to change
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(scrollBefore);
expect(await eventUnderReadingLine(page)).toBe(before);
});
test('shift and the vertical arrows walk the reader event by event', async ({ page, request }) => {
const response = await request.post('/data/db/demo');
expect(response.ok()).toBe(true);
const loadResponse = await request.get('/api/load/index/5');
expect(loadResponse.ok()).toBe(true);
await page.goto('/teleprompter?script=note&followLoaded=false');
const scroller = page.getByTestId('teleprompter-scroller');
await expect(scroller).toBeVisible();
const headings = await page.locator('.teleprompter__heading').allTextContents();
expect(headings.length).toBeGreaterThan(2);
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[0]);
await page.keyboard.press('Shift+ArrowDown');
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[1]);
await page.keyboard.press('Shift+ArrowDown');
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[2]);
await page.keyboard.press('Shift+ArrowUp');
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[1]);
});
test('follow tolerates a small scroll and breaks on a real one, like the operator view', async ({ page, request }) => {
const response = await request.post('/data/db/demo');
expect(response.ok()).toBe(true);
const loadResponse = await request.get('/api/load/index/5');
expect(loadResponse.ok()).toBe(true);
await page.goto('/teleprompter?script=note');
const scroller = page.getByTestId('teleprompter-scroller');
const follow = page.getByTestId('teleprompter-follow');
await expect(scroller).toBeVisible();
await expect(follow).toBeDisabled();
await page.mouse.move(960, 500);
await page.mouse.wheel(0, 15);
await expect(follow).toBeDisabled();
await page.mouse.wheel(0, 400);
await expect(follow).toBeEnabled();
await page.keyboard.press('l');
await expect(follow).toBeDisabled();
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.12.0",
"version": "4.13.0-beta.1",
"description": "Time keeping for live events",
"keywords": [
"ontime",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "4.12.0",
"version": "4.13.0-beta.1",
"name": "ontime-types",
"type": "module",
"main": "./src/index.ts",
@@ -11,6 +11,7 @@ export enum OntimeView {
StudioClock = 'studio',
Countdown = 'countdown',
ProjectInfo = 'info',
Teleprompter = 'teleprompter',
}
export type OntimeViewPresettable = Exclude<OntimeView, OntimeView.Editor>;