Compare commits

...

5 Commits

Author SHA1 Message Date
Claude 15dac35748 feat(teleprompter): honour the shared Flip Screen toggle
The teleprompter was the only view ignoring the global Flip Screen option,
so it now composes with the per view flips.

The two turn out to be the same mechanism: the shared `.mirror` class is
rotate(180deg), which is the same matrix as scale(-1, -1), so Flip Screen
is exactly a flip on both axes at once. Folding it in with XOR means the
teleprompter matches every other view when Flip Screen is on, and there is
still only one transform on the element.

It cannot replace the per view flips. A rotation preserves handedness, so
it never produces the mirror image a beam splitter reflection needs, and it
cannot address one axis on its own. Those remain URL params, which also
lets a shared link to the talent screen carry the rig's setup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
2026-08-13 05:25:44 +00:00
Claude f806244586 feat(teleprompter): add teleprompter view
Adds a teleprompter at /teleprompter which builds its script from the
rundown rather than from an uploaded file, so the read follows the show.

The script comes from a text custom field chosen per view, and the whole
rundown renders as one continuous document with a heading per segment.
That is how broadcast prompters work: the operator scrolls to the right
section as the show moves, so a hard cut on every event change would take
the tail of the line the talent is still reading. Following the loaded
event is a soft jump which releases when the user scrolls by hand, the
same interaction the operator view already uses.

Controls are local, and match the convention shared by prompter software:
space to run, arrows for speed and nudge, home to rewind, F to flip. That
convention doubles as the hardware protocol, since foot pedals and hand
controllers are USB HID devices emitting these keystrokes, so they work
with no setup. Space is claimed back from the navigation menu for the
lifetime of the view via a small store, since the router renders the menu
generically for presets and a prop would not reach it.

Scrolling uses native scrollTop on an overflow container, with the
animation frame loop as its only writer. Position is kept as a float in a
ref: at readable speeds the per frame movement is well under a pixel, so
rounding every frame would stall the scroll, and holding it in state would
re-render the document sixty times a second. Scroll anchoring and smooth
scroll behaviour are both disabled because each would be a second writer.

Notable details:
- flip applies to the view root, so a beam splitter inverts the scroll
  direction along with the text
- content padding is derived from the viewport height, not a percentage,
  which resolves against width and would strand the first line
- image custom fields are refused even when typed into the URL
- script text is rendered as a text node, never as markup

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
2026-08-13 05:20:46 +00:00
Carlos Valente c6eccec30e refactor(settings): show new app indicator 2026-08-09 16:48:20 +02:00
Carlos Valente 5220c2c374 fix(settings): prevent loader overflow 2026-08-09 16:48:20 +02:00
Carlos Valente 4eeeb294f7 chore: update electron navigation 2026-08-09 16:48:20 +02:00
32 changed files with 2527 additions and 6 deletions
+11
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()} />
<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,
};
/**
@@ -3,6 +3,7 @@ import { memo } from 'react';
import { useSearchParams } from 'react-router';
import { hasCustomParams } from '../../stores/savedViewParams';
import { useViewHotkeysStore } from '../../stores/viewHotkeys';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation';
import NavigationMenu from './NavigationMenu';
@@ -27,6 +28,8 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
'Space',
() => {
if (isNavigationLocked) return;
// a view can take over Space for itself, see viewHotkeys store
if (useViewHotkeysStore.getState().spaceClaimed) return;
menuHandler.toggle();
},
{ preventDefault: true },
@@ -0,0 +1,23 @@
import { create } from 'zustand';
interface ViewHotkeysStore {
/** a view has taken over the Space key and the navigation menu must not react to it */
spaceClaimed: boolean;
claimSpace: () => void;
releaseSpace: () => void;
}
/**
* Lets a view take ownership of a hotkey which the view chrome also binds.
*
* The navigation menu binds Space at document level, but the teleprompter needs
* it for playback, which is the convention every prompter and every foot pedal
* follows. Prop threading does not work here because AppRouter renders
* ViewNavigationMenu generically for presets, so the claim lives in a store
* which the view sets on mount and clears on unmount.
*/
export const useViewHotkeysStore = create<ViewHotkeysStore>((set) => ({
spaceClaimed: false,
claimSpace: () => set({ spaceClaimed: true }),
releaseSpace: () => set({ spaceClaimed: false }),
}));
@@ -200,8 +200,7 @@ $card-padding: 2rem;
.overlay {
position: absolute;
z-index: $zindex-backdrop;
width: 100%;
height: 100%;
inset: 0;
backdrop-filter: blur(2px);
display: grid;
place-content: center;
@@ -0,0 +1,7 @@
.updateIndicator {
width: 0.5em;
height: 0.5em;
flex: 0 0 auto;
border-radius: 99px;
background-color: $red-400;
}
@@ -3,6 +3,8 @@ import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './AppVersion.module.scss';
export default function AppVersion() {
const { data, isError } = useAppVersion();
@@ -18,7 +20,12 @@ export default function AppVersion() {
return (
<Panel.ListItem>
<Panel.Field
title={`Ontime ${appVersion}`}
title={
<>
<span className={style.updateIndicator} aria-hidden='true' />
{`Ontime ${appVersion}`}
</>
}
description={
isOntimeCloud
? `Version ${data.version} is available. Restart your stage to update.`
@@ -26,7 +33,7 @@ export default function AppVersion() {
}
/>
{!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
)}
</Panel.ListItem>
);
@@ -26,6 +26,7 @@ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
{ value: OntimeView.StudioClock, label: 'Studio Clock' },
{ value: OntimeView.Countdown, label: 'Countdown' },
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
{ value: OntimeView.Teleprompter, label: 'Teleprompter' },
];
const formId = 'url-preset-form';
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Loader isLoading={status === 'pending'} />
{rootError && <Panel.Error>{rootError}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{data.pendingRestart && (
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
)}
@@ -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}`,
+1
View File
@@ -5,6 +5,7 @@ export const navigatorConstants = [
{ url: 'studio', label: 'Studio Clock' },
{ url: 'countdown', label: 'Countdown' },
{ url: 'info', label: 'Project Info' },
{ url: 'teleprompter', label: 'Teleprompter' },
];
// default time format to use for users in 12 hour clocks
@@ -0,0 +1,274 @@
@use '@/theme/viewerDefs' as *;
.teleprompter {
--tp-flip-x: 1;
--tp-flip-y: 1;
position: relative;
height: 100dvh;
width: 100%;
overflow: hidden;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, var(--tp-background, #000000));
color: var(--color-override, var(--tp-color, #ffffff));
/**
* The flip is applied to the whole view rather than to the text alone.
* A beam splitter reflects everything, so inverting only the text would leave
* the scroll direction reading backwards against the words.
* No transition: a flip mid show has to be instant.
*/
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;
/**
* Chrome's scroll anchoring silently adjusts scrollTop when content above the
* viewport changes height, and the rundown refetches on a timer while a show
* is running. It would be a second writer of scrollTop, fighting our loop.
*/
overflow-anchor: none;
/* the animation frame loop owns scrollTop: never let CSS animate our writes */
scroll-behavior: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.teleprompter__content {
width: var(--tp-text-width, 90%);
margin-inline: auto;
font-size: var(--tp-font-size, 64px);
line-height: var(--tp-line-height, 1.5);
text-align: var(--tp-align, left);
/**
* The first line has to be able to reach the reading line, and the last line
* has to be able to scroll all the way up to it. Without the bottom padding
* the script simply cannot be read to the end.
*
* These are expressed against the viewport height rather than as a percentage:
* percentage padding resolves against the containing block's *width*, which
* would put the first line nowhere near the reading line.
*/
padding-top: calc(var(--tp-reading-line-offset, 40) * 1dvh);
padding-bottom: calc(100dvh - var(--tp-reading-line-offset, 40) * 1dvh);
}
.teleprompter__block {
margin-bottom: 1.5em;
&[data-loaded] .teleprompter__heading {
color: $accent-color;
}
}
.teleprompter__group {
font-size: 0.4em;
text-transform: uppercase;
letter-spacing: 0.1em;
color: $viewer-label-color;
margin-bottom: 0.5em;
}
.teleprompter__heading {
font-size: 0.45em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $viewer-secondary-color;
margin-bottom: 0.4em;
}
.teleprompter__body {
/* operators write their own line breaks, they are part of the read */
white-space: pre-wrap;
overflow-wrap: break-word;
}
/* ---------------------------------------------------------------- overlays */
/**
* All overlays are absolutely positioned, never fixed: the flip transform on the
* view root makes it the containing block for fixed descendants.
*/
.teleprompter__dim {
position: absolute;
inset: 0 0 auto 0;
height: var(--tp-reading-line, 40%);
pointer-events: none;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0));
}
.teleprompter__reading-line {
position: absolute;
left: 0;
right: 0;
top: var(--tp-reading-line, 40%);
pointer-events: none;
&--line {
border-top: 2px solid rgba($accent-color, 0.6);
}
&--shade {
top: 0;
height: var(--tp-reading-line, 40%);
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0));
}
}
.teleprompter__arrow {
position: absolute;
top: -0.6em;
width: 0;
height: 0;
border-top: 0.6em solid transparent;
border-bottom: 0.6em solid transparent;
font-size: clamp(16px, 2vw, 32px);
&--left {
left: 0;
border-left: 0.9em solid $accent-color;
}
&--right {
right: 0;
border-right: 0.9em solid $accent-color;
}
}
.teleprompter__controls {
position: absolute;
bottom: min(2vh, 16px);
left: 50%;
transform: translateX(-50%);
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;
/* stay out of the eyeline while the script is rolling, until pointed at */
&--idle {
opacity: 0;
}
&:hover,
&:focus-within {
opacity: 1;
}
}
.teleprompter__control {
display: grid;
place-items: center;
width: 2em;
height: 2em;
font-size: clamp(14px, 1.4vw, 22px);
color: $viewer-color;
background: rgba(white, 8%);
border-radius: $element-border-radius;
cursor: pointer;
&:hover {
background: rgba(white, 16%);
}
&--attention {
color: $accent-color;
}
}
.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: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.75);
}
.teleprompter__help-card {
max-height: 80%;
overflow-y: auto;
padding: clamp(16px, 2vw, 24px);
background: $viewer-card-bg-color;
border-radius: $element-border-radius;
font-size: $base-font-size;
}
.teleprompter__help-title {
font-size: $title-font-size;
margin-bottom: $view-element-gap;
}
.teleprompter__help-row {
display: flex;
gap: clamp(16px, 2vw, 24px);
padding: 0.25em 0;
}
.teleprompter__help-keys {
flex: 0 0 10em;
color: $viewer-color;
font-variant-numeric: tabular-nums;
}
.teleprompter__help-action {
color: $viewer-secondary-color;
}
.teleprompter__help-note {
margin-top: $view-element-gap;
color: $viewer-label-color;
}
.teleprompter__help-close {
margin-top: $view-element-gap;
padding: 0.4em 1em;
color: $viewer-color;
background: rgba(white, 8%);
border-radius: $element-border-radius;
cursor: pointer;
}
@@ -0,0 +1,186 @@
import { OntimeView } from 'ontime-types';
import { type CSSProperties, useCallback, useMemo, 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 ReadingLine from './reading-line/ReadingLine';
import ScriptBlockView from './script-block/ScriptBlock';
import { getTeleprompterOptions, useTeleprompterOptions } from './teleprompter.options';
import { clampFontScale } from './teleprompter.scroll';
import { buildScript, composeFlip } from './teleprompter.utils';
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) {
const options = useTeleprompterOptions();
const selectedEventId = useSelectedEventId();
// the shared "Flip Screen" toggle from the navigation menu
const isMirrored = useViewOptionsStore((state) => state.mirror);
const [fontScale, setFontScale] = useState(1);
const [flipH, setFlipH] = useState(options.flipH);
const [flipV, setFlipV] = useState(options.flipV);
const [showHelp, setShowHelp] = useState(false);
const viewOptions = useMemo(() => getTeleprompterOptions(customFields), [customFields]);
const blocks = useMemo(
() =>
buildScript(rundown, rundownMetadata, customFields, {
scriptSource: options.scriptSource,
heading: options.heading,
hideEmpty: options.hideEmpty,
hidePast: options.hidePast,
showGroups: options.showGroups,
}),
[
rundown,
rundownMetadata,
customFields,
options.scriptSource,
options.heading,
options.hideEmpty,
options.hidePast,
options.showGroups,
],
);
// a cheap identity for the rendered document, used to trigger a remeasure
const contentKey = useMemo(
() => `${blocks.length}-${options.fontSize}-${fontScale}-${options.textWidth}`,
[blocks.length, options.fontSize, fontScale, options.textWidth],
);
const {
scrollerRef,
contentRef,
registerBlock,
handleUserScroll,
controller,
isRunning,
speed,
followLocked,
atEnd,
} = useTeleprompterScroll({
initialSpeed: options.speed,
autoplay: options.autoplay,
followLoaded: options.followLoaded,
selectedEventId,
readingLinePos: options.readingLinePos,
contentKey,
});
const handleFlip = useCallback((axis: 'h' | 'v') => {
if (axis === 'h') {
setFlipH((current) => !current);
} else {
setFlipV((current) => !current);
}
}, []);
const handleFontSize = useCallback((delta: number) => {
setFontScale((current) => clampFontScale(current + delta));
}, []);
const handleResetFontSize = useCallback(() => setFontScale(1), []);
const handleToggleHelp = useCallback(() => setShowHelp((current) => !current), []);
useTeleprompterControls({
controller,
onFlip: handleFlip,
onFontSize: handleFontSize,
onResetFontSize: handleResetFontSize,
onToggleHelp: handleToggleHelp,
});
const hasScriptSource = Boolean(options.scriptSource) && options.scriptSource !== 'none';
// Flip Screen is a flip on both axes, so it folds into the per view flips
const flip = composeFlip(flipH, flipV, isMirrored);
const viewStyles = {
'--tp-font-size': `${options.fontSize * fontScale}px`,
'--tp-line-height': options.lineHeight,
'--tp-text-width': `${options.textWidth}%`,
// as a percentage for the overlays, which are positioned against the height,
// and unitless for the content padding, which is scaled by dvh instead
'--tp-reading-line': `${options.readingLinePos}%`,
'--tp-reading-line-offset': options.readingLinePos,
'--tp-align': options.align,
'--tp-background': options.keyColour ?? '#000000',
'--tp-color': options.textColour ?? '#ffffff',
...(options.font ? { '--font-family-override': options.font } : {}),
} as CSSProperties;
return (
<div
className={cx(['teleprompter', flip.flipH && 'teleprompter--flip-h', flip.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}
onWheel={handleUserScroll}
onTouchMove={handleUserScroll}
onPointerDown={handleUserScroll}
>
<div className='teleprompter__content' ref={contentRef}>
{blocks.map((block) => (
<ScriptBlockView key={block.id} block={block} registerRef={registerBlock(block.id)} />
))}
</div>
</div>
<ReadingLine variant={options.readingLine} dimPast={options.dimPast} />
<ControlOverlay
isRunning={isRunning}
speed={speed}
followLocked={followLocked && options.followLoaded}
atEnd={atEnd}
controller={controller}
onToggleHelp={handleToggleHelp}
/>
</>
)}
{showHelp && <HelpOverlay onClose={handleToggleHelp} />}
</div>
);
}
@@ -0,0 +1,104 @@
import { resolveTeleprompterAction, type TeleprompterKeyEvent } from '../teleprompter.keymap';
import { FONT_SCALE_STEP } 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', () => {
// a held key, or a pressed foot pedal, otherwise toggles playback dozens of times
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('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: 2 });
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowLeft' }))).toEqual({ type: 'speed', delta: -2 });
});
test('shift makes the speed step coarse', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight', shiftKey: true }))).toEqual({
type: 'speed',
delta: 10,
});
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowLeft', shiftKey: true }))).toEqual({
type: 'speed',
delta: -10,
});
});
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('font size is resolved by key so it survives other layouts', () => {
expect(resolveTeleprompterAction(makeEvent({ key: '+' }))).toMatchObject({ type: 'fontSize' });
expect(resolveTeleprompterAction(makeEvent({ key: '=' }))).toMatchObject({ type: 'fontSize' });
expect(resolveTeleprompterAction(makeEvent({ key: '-' }))?.type).toBe('fontSize');
expect(resolveTeleprompterAction(makeEvent({ key: '0' }))).toEqual({ type: 'resetFontSize' });
});
test('plus increases and minus decreases', () => {
expect(resolveTeleprompterAction(makeEvent({ key: '+' }))).toEqual({
type: 'fontSize',
delta: FONT_SCALE_STEP,
});
expect(resolveTeleprompterAction(makeEvent({ key: '-' }))).toEqual({
type: 'fontSize',
delta: -FONT_SCALE_STEP,
});
});
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', () => {
// mod+, opens the view params and must keep working
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();
});
});
@@ -0,0 +1,91 @@
import { getOptionsFromParams } 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: null,
heading: 'title',
hideEmpty: true,
hidePast: false,
showGroups: true,
speed: DEFAULT_SPEED,
autoplay: false,
followLoaded: true,
fontSize: 64,
lineHeight: 1.5,
textWidth: 90,
align: 'left',
dimPast: true,
readingLine: 'line',
readingLinePos: 40,
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&dimPast=false'),
);
expect(options).toMatchObject({
hideEmpty: false,
showGroups: false,
followLoaded: false,
dimPast: false,
});
});
test('booleans which default to false can be turned on', () => {
const options = getOptionsFromParams(new URLSearchParams('hidePast=true&autoplay=true&flipH=true&flipV=true'));
expect(options).toMatchObject({
hidePast: true,
autoplay: true,
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', () => {
// Number(null) is 0, so a naive parse would silently produce a speed of zero
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(64);
});
test('rejects an unknown value for an enumerated option', () => {
expect(getOptionsFromParams(new URLSearchParams('heading=banana')).heading).toBe('title');
expect(getOptionsFromParams(new URLSearchParams('readingLine=banana')).readingLine).toBe('line');
expect(getOptionsFromParams(new URLSearchParams('align=banana')).align).toBe('left');
});
test('adds the hash back to colour params', () => {
const options = getOptionsFromParams(new URLSearchParams('keyColour=112233&textColour=ffee00'));
expect(options.keyColour).toBe('#112233');
expect(options.textColour).toBe('#ffee00');
});
test('preset values take precedence over the search params', () => {
const options = getOptionsFromParams(
new URLSearchParams('speed=10&script=custom-a'),
new URLSearchParams('speed=50&script=custom-b'),
);
expect(options.speed).toBe(50);
expect(options.scriptSource).toBe('custom-b');
});
});
@@ -0,0 +1,145 @@
import {
advance,
clampFontScale,
clampSpeed,
easeCatchUp,
frameDeltaSeconds,
hasArrived,
linesPerMinuteToPxPerSecond,
MAX_FRAME_DELTA_MS,
MAX_SPEED,
MIN_SPEED,
} 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);
});
test('is zero before the line height has been measured', () => {
expect(linesPerMinuteToPxPerSecond(30, 0)).toBe(0);
});
});
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', () => {
expect(clampSpeed(Number.NaN)).toBe(MIN_SPEED);
});
});
describe('clampFontScale()', () => {
test('bounds the font scale', () => {
expect(clampFontScale(0.1)).toBe(0.4);
expect(clampFontScale(10)).toBe(3);
});
});
describe('frameDeltaSeconds()', () => {
test('converts milliseconds to seconds', () => {
expect(frameDeltaSeconds(50)).toBe(0.05);
expect(frameDeltaSeconds(1000 / 60)).toBeCloseTo(1 / 60, 6);
});
test('clamps a long gap so returning to a background tab cannot teleport the script', () => {
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('clamps at the bounds of the document', () => {
expect(advance(0, -100, 1, 500).position).toBe(0);
expect(advance(490, 100, 1, 500).position).toBe(500);
});
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('never reports the end for a document which does not overflow', () => {
// the document may simply not have been measured yet
expect(advance(0, 100, 1, 0).atEnd).toBe(false);
});
});
describe('easeCatchUp()', () => {
test('moves monotonically towards a target below', () => {
let position = 0;
let previous = -1;
for (let i = 0; i < 20; i += 1) {
position = easeCatchUp(position, 500, 1 / 60);
expect(position).toBeGreaterThan(previous);
expect(position).toBeLessThanOrEqual(500);
previous = position;
}
});
test('moves monotonically towards a target above', () => {
let position = 500;
let previous = 501;
for (let i = 0; i < 20; i += 1) {
position = easeCatchUp(position, 0, 1 / 60);
expect(position).toBeLessThan(previous);
expect(position).toBeGreaterThanOrEqual(0);
previous = position;
}
});
test('converges on the target within a bounded number of frames', () => {
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', () => {
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);
});
test('does not move without time passing', () => {
expect(easeCatchUp(100, 500, 0)).toBe(100);
});
});
describe('hasArrived()', () => {
test('treats a sub-pixel distance as arrived', () => {
expect(hasArrived(100, 100.1)).toBe(true);
expect(hasArrived(100, 105)).toBe(false);
});
});
@@ -0,0 +1,197 @@
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,
hidePast: false,
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: null })).toEqual(
[],
);
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('hidePast drops events which already played', () => {
const rundown = makeRundown([makeEvent('a'), makeEvent('b')]);
const metadata = metadataFor(['a', 'b'], { a: { isPast: true } });
expect(
buildScript(rundown, metadata, customFields, { ...defaultOptions, hidePast: true }).map((b) => b.id),
).toEqual(['b']);
expect(buildScript(rundown, metadata, customFields, defaultOptions).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('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('');
});
});
});
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,88 @@
import { IoArrowUp, IoHelpCircleOutline, IoLocate, IoPause, IoPlay, IoRemove, IoAdd } from 'react-icons/io5';
import { cx } from '../../../common/utils/styleUtils';
import type { TeleprompterController } from '../teleprompter.types';
interface ControlOverlayProps {
isRunning: boolean;
speed: number;
followLocked: boolean;
atEnd: boolean;
controller: TeleprompterController;
onToggleHelp: () => void;
}
/**
* On screen transport, for when nobody is at a keyboard.
* It fades out while the script is rolling so it does not sit in the talent's eyeline.
*/
export default function ControlOverlay({
isRunning,
speed,
followLocked,
atEnd,
controller,
onToggleHelp,
}: ControlOverlayProps) {
return (
<div className={cx(['teleprompter__controls', isRunning && 'teleprompter__controls--idle'])}>
<button
type='button'
className='teleprompter__control'
onClick={controller.togglePlay}
data-testid='teleprompter-play'
aria-label={isRunning ? 'Pause' : 'Play'}
>
{isRunning ? <IoPause /> : <IoPlay />}
</button>
<button
type='button'
className='teleprompter__control'
onClick={() => controller.changeSpeed(-2)}
aria-label='Slow down'
>
<IoRemove />
</button>
<div className='teleprompter__speed' data-testid='teleprompter-speed'>
{speed}
<span className='teleprompter__speed-unit'>lpm</span>
</div>
<button
type='button'
className='teleprompter__control'
onClick={() => controller.changeSpeed(2)}
aria-label='Speed up'
>
<IoAdd />
</button>
<button
type='button'
className={cx(['teleprompter__control', atEnd && 'teleprompter__control--attention'])}
onClick={() => controller.rewind()}
aria-label='Rewind to top'
>
<IoArrowUp />
</button>
{followLocked && (
<button
type='button'
className='teleprompter__control teleprompter__control--attention'
onClick={controller.reengageFollow}
data-testid='teleprompter-follow'
aria-label='Follow the loaded event'
>
<IoLocate />
</button>
)}
<button type='button' className='teleprompter__control' onClick={onToggleHelp} aria-label='Keyboard shortcuts'>
<IoHelpCircleOutline />
</button>
</div>
);
}
@@ -0,0 +1,46 @@
interface HelpOverlayProps {
onClose: () => void;
}
const shortcuts: { keys: string; action: string }[] = [
{ keys: 'Space', action: 'Start / stop scrolling' },
{ keys: '← / →', action: 'Slower / faster (hold Shift for larger steps)' },
{ keys: '↑ / ↓', action: 'Nudge one line' },
{ keys: 'Page Up / Page Down', action: 'Jump a screen' },
{ keys: 'Home', action: 'Rewind to the top' },
{ keys: 'End', action: 'Jump to the end' },
{ keys: 'Esc', action: 'Rewind and stop' },
{ keys: '+ / -', action: 'Font size' },
{ keys: '0', action: 'Reset font size' },
{ keys: 'F / Shift + F', action: 'Flip horizontally / vertically' },
{ keys: 'L', action: 'Follow the loaded event again' },
{ keys: '?', action: 'Show this list' },
];
/**
* Most prompter foot pedals and hand controllers are USB HID devices which send
* these same keystrokes, so this list doubles as the hardware reference.
*/
export default function HelpOverlay({ onClose }: HelpOverlayProps) {
return (
<div className='teleprompter__help' role='dialog' aria-label='Keyboard shortcuts'>
<div className='teleprompter__help-card'>
<div className='teleprompter__help-title'>Controls</div>
<dl className='teleprompter__help-list'>
{shortcuts.map(({ keys, action }) => (
<div key={keys} className='teleprompter__help-row'>
<dt className='teleprompter__help-keys'>{keys}</dt>
<dd className='teleprompter__help-action'>{action}</dd>
</div>
))}
</dl>
<div className='teleprompter__help-note'>
Foot pedals and hand controllers which emit these keys work without any setup.
</div>
<button type='button' className='teleprompter__help-close' onClick={onClose}>
Close
</button>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import { cx } from '../../../common/utils/styleUtils';
import type { ReadingLineVariant } from '../teleprompter.types';
interface ReadingLineProps {
variant: ReadingLineVariant;
dimPast: boolean;
}
/**
* The eye-line indicator: it marks where on the screen the talent should read,
* which keeps their eyeline near the lens instead of tracking down the page.
*/
export default function ReadingLine({ variant, dimPast }: ReadingLineProps) {
if (variant === 'none' && !dimPast) {
return null;
}
return (
<>
{dimPast && <div className='teleprompter__dim' />}
{variant !== 'none' && (
<div className={cx(['teleprompter__reading-line', `teleprompter__reading-line--${variant}`])}>
{variant === 'arrows' && (
<>
<span className='teleprompter__arrow teleprompter__arrow--left' />
<span className='teleprompter__arrow teleprompter__arrow--right' />
</>
)}
</div>
)}
</>
);
}
@@ -0,0 +1,23 @@
import { memo } from 'react';
import type { ScriptBlock } from '../teleprompter.types';
interface ScriptBlockProps {
block: ScriptBlock;
registerRef: (element: HTMLElement | null) => void;
}
export default memo(ScriptBlockView);
function ScriptBlockView({ block, registerRef }: ScriptBlockProps) {
return (
<section className='teleprompter__block' ref={registerRef} data-loaded={block.isLoaded || undefined}>
{block.groupTitle && <div className='teleprompter__group'>{block.groupTitle}</div>}
{block.heading && <h2 className='teleprompter__heading'>{block.heading}</h2>}
{/* the script is user data, it is rendered as text and never as markup */}
<p className='teleprompter__body' data-prompter-body>
{block.text}
</p>
</section>
);
}
@@ -0,0 +1,87 @@
import { FONT_SCALE_STEP } from './teleprompter.scroll';
import type { TeleprompterAction } from './teleprompter.types';
/** speed step in lines per minute */
const SPEED_STEP = 2;
const SPEED_STEP_COARSE = 10;
/**
* The subset of a KeyboardEvent the keymap needs.
* Declaring it structurally keeps `resolveTeleprompterAction` testable with plain objects.
*/
export type TeleprompterKeyEvent = {
code: string;
key: string;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
altKey: boolean;
repeat: boolean;
};
/**
* Maps a keyboard event onto a teleprompter action.
*
* The mapping deliberately matches the convention shared by prompter software
* (space to run, arrows for speed, home to rewind). That convention is also the
* hardware protocol: foot pedals and hand controllers are USB HID devices which
* emit these very keystrokes, so matching it is what makes them work here.
*
* @returns the action to run, or null when the view should ignore the event
*/
export function resolveTeleprompterAction(event: TeleprompterKeyEvent): TeleprompterAction | null {
// never shadow browser or Ontime shortcuts, notably mod+, which opens the view params
if (event.ctrlKey || event.metaKey || event.altKey) {
return null;
}
switch (event.code) {
case 'Space':
// a held key or a pressed foot pedal repeats: without this guard a single
// press toggles playback dozens of times
return event.repeat ? null : { type: 'togglePlay' };
case 'ArrowDown':
return { type: 'nudge', lines: 1 };
case 'ArrowUp':
return { 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' };
}
// the remaining bindings are resolved by key rather than code so that they
// survive non-QWERTY layouts
switch (event.key) {
case '?':
return { type: 'toggleHelp' };
case '+':
case '=':
return { type: 'fontSize', delta: FONT_SCALE_STEP };
case '-':
case '_':
return { type: 'fontSize', delta: -FONT_SCALE_STEP };
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,279 @@
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, makeColourString } from '../common/viewUtils';
import { clampSpeed, DEFAULT_SPEED, MAX_SPEED, MIN_SPEED } from './teleprompter.scroll';
import type { HeadingSource, ReadingLineVariant, TeleprompterOptions } from './teleprompter.types';
const headingOptions = [
{ value: 'title', label: 'Title' },
{ value: 'cue', label: 'Cue' },
{ value: 'both', label: 'Cue and title' },
{ value: 'none', label: 'None' },
];
const readingLineOptions = [
{ value: 'line', label: 'Line' },
{ value: 'arrows', label: 'Arrows' },
{ value: 'shade', label: 'Shade' },
{ value: 'none', label: 'None' },
];
const alignOptions = [
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Centre' },
];
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: 'none',
},
{
id: 'heading',
title: 'Segment heading',
description: 'What to show above each segment of the script',
type: 'option',
values: headingOptions,
defaultValue: 'title',
},
],
},
{
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: DEFAULT_SPEED,
},
{
id: 'autoplay',
title: 'Start scrolling on load',
description: 'Whether the script starts scrolling as soon as the view opens',
type: 'boolean',
defaultValue: false,
},
{
id: 'followLoaded',
title: 'Follow loaded event',
description: 'Scroll to the segment of the loaded event. Scrolling by hand releases the follow',
type: 'boolean',
defaultValue: true,
},
],
},
{
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: true,
},
{
id: 'hidePast',
title: 'Hide past events',
description: 'Prevents showing events which have already played',
type: 'boolean',
defaultValue: false,
},
{
id: 'showGroups',
title: 'Show group names',
description: 'Shows the group name when the script moves into a new group',
type: 'boolean',
defaultValue: true,
},
],
},
{
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: 64,
},
{
id: 'lineHeight',
title: 'Line height',
description: 'Spacing between lines, as a multiple of the font size',
type: 'number',
defaultValue: 1.5,
},
{
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: 90,
},
{
id: 'align',
title: 'Text alignment',
description: 'Alignment of the script text',
type: 'option',
values: alignOptions,
defaultValue: 'left',
},
{
id: 'readingLine',
title: 'Reading line',
description: 'Style of the eye-line indicator which marks where to read',
type: 'option',
values: readingLineOptions,
defaultValue: 'line',
},
{
id: 'readingLinePos',
title: 'Reading line position',
description: 'Position of the reading line as a percentage from the top of the screen',
type: 'number',
defaultValue: 40,
},
{
id: 'dimPast',
title: 'Dim text already read',
description: 'Fades the text above the reading line',
type: 'boolean',
defaultValue: true,
},
{
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: false,
},
{
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: false,
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Open Sans (default)',
},
{
id: 'keyColour',
title: 'Key Colour',
description: 'Background or key colour for entire view. Default: #000000',
type: 'colour',
defaultValue: '000000',
},
{
id: 'textColour',
title: 'Text Colour',
description: 'Colour of the script text. Default: #ffffff',
type: 'colour',
defaultValue: 'ffffff',
},
],
},
];
};
/**
* Parses a numeric param, guarding against the Number(null) === 0 trap.
*/
function toNumberInRange(value: string | null, min: number, max: 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 toEnum<T extends string>(value: string | null, allowed: readonly T[], fallback: T): T {
return allowed.includes(value as T) ? (value as T) : fallback;
}
const headingSources: readonly HeadingSource[] = ['none', 'title', 'cue', 'both'];
const readingLineVariants: readonly ReadingLineVariant[] = ['none', 'line', 'arrows', 'shade'];
/**
* Utility extract the view options from URL Params
* the names and fallbacks are manually matched with getTeleprompterOptions
*/
export function getOptionsFromParams(
searchParams: URLSearchParams,
defaultValues?: URLSearchParams,
): TeleprompterOptions {
// Helper to get value from either source, prioritizing defaultValues
const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
return {
scriptSource: getValue('script'),
heading: toEnum(getValue('heading'), headingSources, 'title'),
hideEmpty: getValue('hideEmpty') === null ? true : isStringBoolean(getValue('hideEmpty')),
hidePast: isStringBoolean(getValue('hidePast')),
showGroups: getValue('showGroups') === null ? true : isStringBoolean(getValue('showGroups')),
speed: clampSpeed(toNumberInRange(getValue('speed'), MIN_SPEED, MAX_SPEED, DEFAULT_SPEED)),
autoplay: isStringBoolean(getValue('autoplay')),
followLoaded: getValue('followLoaded') === null ? true : isStringBoolean(getValue('followLoaded')),
fontSize: toNumberInRange(getValue('fontSize'), 12, 400, 64),
lineHeight: toNumberInRange(getValue('lineHeight'), 1, 4, 1.5),
textWidth: toNumberInRange(getValue('textWidth'), 20, 100, 90),
align: toEnum(getValue('align'), ['left', 'center'] as const, 'left'),
dimPast: getValue('dimPast') === null ? true : isStringBoolean(getValue('dimPast')),
readingLine: toEnum(getValue('readingLine'), readingLineVariants, 'line'),
readingLinePos: toNumberInRange(getValue('readingLinePos'), 0, 100, 40),
flipH: isStringBoolean(getValue('flipH')),
flipV: isStringBoolean(getValue('flipV')),
font: getValue('font') ?? undefined,
keyColour: makeColourString(getValue('keyColour')),
textColour: makeColourString(getValue('textColour')),
};
}
/**
* Hook exposes the teleprompter view options
*/
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,100 @@
/**
* Pure scroll arithmetic for the teleprompter.
*
* Everything here is side effect free so it can be unit tested without a DOM.
* The hook that owns the requestAnimationFrame loop is the only caller.
*/
/** lines per minute */
export const MIN_SPEED = 2;
export const MAX_SPEED = 200;
export const DEFAULT_SPEED = 30;
/** font size multiplier applied on top of the configured size by the +/- keys */
export const MIN_FONT_SCALE = 0.4;
export const MAX_FONT_SCALE = 3;
export const FONT_SCALE_STEP = 0.1;
/**
* requestAnimationFrame is suspended in background tabs, so the timestamp can
* jump by minutes when the view becomes visible again.
* We clamp the frame delta so a resume can never teleport the script.
*/
export const MAX_FRAME_DELTA_MS = 100;
/** how aggressively an eased jump converges on its target, per second */
const CATCH_UP_RATE = 8;
/** below this distance an eased jump is considered arrived */
export 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 clampFontScale(value: number): number {
return clamp(value, MIN_FONT_SCALE, MAX_FONT_SCALE);
}
/**
* Converts a speed in lines per minute into pixels per second.
* Lines per minute is the unit prompter operators think in, and it is
* independent of font size, which is why it is what we persist.
*/
export function linesPerMinuteToPxPerSecond(linesPerMinute: number, lineHeightPx: number): number {
return (linesPerMinute / 60) * lineHeightPx;
}
/** Estimates a words per minute read rate, for display only */
export function linesPerMinuteToWordsPerMinute(linesPerMinute: number, wordsPerLine: number): number {
return Math.round(linesPerMinute * wordsPerLine);
}
/**
* Clamps a raw frame delta and converts it to seconds.
* @param deltaMs milliseconds since the previous frame
*/
export function frameDeltaSeconds(deltaMs: number): number {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return 0;
return Math.min(deltaMs, MAX_FRAME_DELTA_MS) / 1000;
}
/**
* Advances the scroll position at a constant rate.
*
* The position is kept as a float by the caller: at readable prompter speeds the
* per frame delta is well under a pixel, so rounding on every frame would stall
* the scroll entirely. We return the exact float and let the DOM round on write.
*/
export function advance(
position: number,
pxPerSecond: number,
deltaSeconds: number,
maxScroll: number,
): { position: number; atEnd: boolean } {
const next = clamp(position + pxPerSecond * deltaSeconds, 0, Math.max(maxScroll, 0));
// a document which does not overflow is never "at the end": it may simply not
// have been measured yet, and stopping playback on that would be wrong
return { position: next, atEnd: maxScroll > 0 && next >= maxScroll };
}
/**
* Moves `current` towards `target` with an exponential ease.
*
* Framerate independent: the same wall clock duration produces the same curve
* regardless of how many frames it was sampled over.
*/
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;
}
export function hasArrived(current: number, target: number): boolean {
return Math.abs(current - target) < CATCH_UP_EPSILON;
}
@@ -0,0 +1,77 @@
import type { MaybeString } from 'ontime-types';
/** What the per-event heading shows above each script block */
export type HeadingSource = 'none' | 'title' | 'cue' | 'both';
/** Style of the eye-line indicator */
export type ReadingLineVariant = 'none' | 'line' | 'arrows' | 'shade';
/** A single readable segment of the prompter document */
export type ScriptBlock = {
/** entry id, used as the follow target and the react key */
id: string;
/** heading text, already resolved from the heading option. Empty string when suppressed */
heading: string;
/** the script itself */
text: string;
/** group title, present only on the first block of a new group */
groupTitle: MaybeString;
/** the block belongs to the currently loaded event */
isLoaded: boolean;
};
export type TeleprompterOptions = {
/** 'custom-<key>' | 'note' | 'title' | 'none' | null */
scriptSource: string | null;
heading: HeadingSource;
hideEmpty: boolean;
hidePast: boolean;
showGroups: boolean;
/** lines per minute */
speed: number;
autoplay: boolean;
followLoaded: boolean;
fontSize: number;
lineHeight: number;
textWidth: number;
align: 'left' | 'center';
font?: string;
keyColour?: string;
textColour?: string;
dimPast: boolean;
readingLine: ReadingLineVariant;
readingLinePos: number;
flipH: boolean;
flipV: boolean;
};
/**
* A discrete intent resolved from a keyboard event.
* Kept as data so the keymap can be unit tested without a DOM.
*/
export type TeleprompterAction =
| { type: 'togglePlay' }
| { type: 'nudge'; lines: number }
| { type: 'page'; direction: 1 | -1 }
| { type: 'speed'; delta: number }
| { type: 'rewind' }
| { type: 'rewindAndPause' }
| { type: 'jumpToEnd' }
| { type: 'flip'; axis: 'h' | 'v' }
| { type: 'fontSize'; delta: number }
| { type: 'resetFontSize' }
| { type: 'reengageFollow' }
| { type: 'toggleHelp' };
/** The imperative surface the overlay and the key handler drive */
export type TeleprompterController = {
togglePlay: () => void;
play: () => void;
pause: () => void;
nudge: (lines: number) => void;
page: (direction: 1 | -1) => void;
changeSpeed: (delta: number) => void;
rewind: (alsoPause?: boolean) => void;
jumpToEnd: () => void;
reengageFollow: () => void;
};
@@ -0,0 +1,133 @@
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' | 'hidePast' | 'showGroups'
>;
/**
* Resolves the heading shown above a script block.
*/
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 '';
}
}
/**
* An image custom field holds a URL, which is meaningless to read aloud.
* The params editor filters these out of the select, but the URL can be hand typed.
*/
function isReadableSource(scriptSource: string, customFields: CustomFields): boolean {
if (!scriptSource.startsWith('custom-')) {
return true;
}
const key = scriptSource.slice('custom-'.length);
return customFields[key]?.type === 'text';
}
/**
* Flattens the rundown into the continuous document the prompter scrolls through.
*
* We iterate flatOrder so that events nested in groups arrive in reading order
* without a second pass.
*/
export function buildScript(
rundown: Rundown,
rundownMetadata: RundownMetadataObject,
customFields: CustomFields,
options: BuildScriptOptions,
): ScriptBlock[] {
const { scriptSource, heading, hideEmpty, hidePast, showGroups } = options;
if (!scriptSource || 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];
if (hidePast && metadata?.isPast) {
continue;
}
const text = getPropertyValue(entry, scriptSource, rundown.entries)?.trim() ?? '';
if (hideEmpty && !text) {
continue;
}
// a group title is emitted once, on the first block that belongs to it
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;
}
/**
* Folds Ontime's global "Flip Screen" toggle into the per view flips.
*
* The shared `.mirror` class is `rotate(180deg)`, which is the same matrix as
* `scale(-1, -1)`: a flip on both axes at once. So the global toggle is exactly
* the pair of flips this view already has, and composing them with XOR keeps the
* teleprompter behaving like every other view without two transforms competing
* for the same property.
*
* It cannot replace the per view flips, though. A rotation preserves handedness,
* so it never yields the mirror image a beam splitter reflection needs; only a
* single axis flip does. That is why both exist.
*/
export function composeFlip(flipH: boolean, flipV: boolean, isMirrored: boolean): { flipH: boolean; flipV: boolean } {
return { flipH: flipH !== isMirrored, flipV: flipV !== isMirrored };
}
/**
* Rough words per line, used only to show a words per minute estimate in the HUD.
*/
export function estimateWordsPerLine(blocks: ScriptBlock[], charactersPerLine: number): number {
if (blocks.length === 0 || charactersPerLine <= 0) {
return 0;
}
let characters = 0;
let words = 0;
for (const block of blocks) {
characters += block.text.length;
words += block.text.split(/\s+/).filter(Boolean).length;
}
if (characters === 0) return 0;
const averageWordLength = characters / words;
return charactersPerLine / averageWordLength;
}
@@ -0,0 +1,90 @@
import { useEffect, useRef } from 'react';
import { useViewParamsEditorStore } from '../../common/components/view-params-editor/viewParamsEditor.store';
import { useViewHotkeysStore } from '../../common/stores/viewHotkeys';
import { resolveTeleprompterAction } from './teleprompter.keymap';
import type { TeleprompterAction, TeleprompterController } from './teleprompter.types';
interface UseTeleprompterControlsArgs {
controller: TeleprompterController;
onFlip: (axis: 'h' | 'v') => void;
onFontSize: (delta: number) => void;
onResetFontSize: () => void;
onToggleHelp: () => void;
}
const ignoredTags = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
/**
* Binds the prompter keymap.
*
* Space is claimed from the navigation menu for the lifetime of the view: it is
* the universal run/stop key on prompter software and on the centre pedal of
* every three pedal controller, so the view cannot leave it to the menu.
*/
export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
const argsRef = useRef(args);
argsRef.current = args;
const claimSpace = useViewHotkeysStore((state) => state.claimSpace);
const releaseSpace = useViewHotkeysStore((state) => state.releaseSpace);
useEffect(() => {
claimSpace();
return releaseSpace;
}, [claimSpace, releaseSpace]);
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 '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.delta);
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;
}
// the params editor is a form, it owns the keyboard while it is open
if (useViewParamsEditorStore.getState().isOpen) {
return;
}
const action = resolveTeleprompterAction(event);
if (!action) return;
// without this the browser also scrolls the container natively and the
// movement is applied twice
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,338 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { throttle } from '../../common/utils/throttle';
import {
advance,
clamp,
clampSpeed,
easeCatchUp,
frameDeltaSeconds,
hasArrived,
linesPerMinuteToPxPerSecond,
} from './teleprompter.scroll';
import type { TeleprompterController } from './teleprompter.types';
/** how much of a screen a page jump moves */
const PAGE_FRACTION = 0.85;
/** how long the user has to stop scrolling before we consider the gesture over */
const FOLLOW_LOCK_THROTTLE = 1000;
interface UseTeleprompterScrollArgs {
initialSpeed: number;
autoplay: boolean;
followLoaded: boolean;
selectedEventId: string | null;
/** percentage from the top of the screen */
readingLinePos: number;
/** changes whenever the rendered document changes, forcing a remeasure */
contentKey: string;
}
/**
* Owns the scroll position of the teleprompter.
*
* The single most important rule here is that this hook is the *only* writer of
* scrollTop. Everything else (nudges, page jumps, rewinds, following the loaded
* event) writes a target or a delta into a ref, and the animation frame applies
* it. Mixing in a second writer, such as scrollTo({ behavior: 'smooth' }), makes
* the two fight each other and the scroll visibly stutters. That is also why we
* reuse the arithmetic of useFollowComponent rather than the hook itself.
*
* The position is deliberately held in a ref rather than in state: at readable
* prompter speeds the per frame movement is a fraction of a pixel, so this runs
* every animation frame, and re-rendering a long script that often is not viable.
*/
export function useTeleprompterScroll({
initialSpeed,
autoplay,
followLoaded,
selectedEventId,
readingLinePos,
contentKey,
}: UseTeleprompterScrollArgs) {
const scrollerRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const blockRefs = useRef(new Map<string, HTMLElement>());
// authoritative, sub-pixel scroll position
const posRef = useRef(0);
const lastTsRef = useRef(0);
const frameRef = useRef<number | null>(null);
const runningRef = useRef(false);
const speedPxSecRef = useRef(0);
const speedRef = useRef(initialSpeed);
const lineHeightRef = useRef(0);
const maxScrollRef = useRef(0);
const catchUpTargetRef = useRef<number | null>(null);
const pendingDeltaRef = useRef(0);
const adoptScrollRef = useRef(false);
const tickRef = useRef<(ts: number) => void>(() => {});
const [isRunning, setIsRunning] = useState(false);
const [speed, setSpeed] = useState(initialSpeed);
const [followLocked, setFollowLocked] = useState(false);
const [atEnd, setAtEnd] = useState(false);
/** wakes the animation frame loop if it is not already running */
const ensureLoop = useCallback(() => {
if (frameRef.current !== null) return;
lastTsRef.current = performance.now();
frameRef.current = requestAnimationFrame((ts) => tickRef.current(ts));
}, []);
/** recomputes the scroll bounds and the line height the speed is derived from */
const measure = useCallback(() => {
const scroller = scrollerRef.current;
const content = contentRef.current;
if (!scroller || !content) return;
maxScrollRef.current = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
const sample = content.querySelector('[data-prompter-body]');
if (sample) {
const computed = getComputedStyle(sample);
const parsedLineHeight = Number.parseFloat(computed.lineHeight);
if (Number.isFinite(parsedLineHeight) && parsedLineHeight > 0) {
lineHeightRef.current = parsedLineHeight;
} else {
// line-height resolves to the string 'normal' when it is not set explicitly
const parsedFontSize = Number.parseFloat(computed.fontSize);
if (Number.isFinite(parsedFontSize)) {
lineHeightRef.current = parsedFontSize * 1.5;
}
}
}
speedPxSecRef.current = linesPerMinuteToPxPerSecond(speedRef.current, lineHeightRef.current);
}, []);
tickRef.current = (ts: number) => {
const el = scrollerRef.current;
if (!el) {
frameRef.current = null;
return;
}
// the user scrolled by hand: adopt the browser's position as the truth.
// this is read here, rather than in the event handler, because the browser
// has by now applied the scroll
if (adoptScrollRef.current) {
posRef.current = el.scrollTop;
catchUpTargetRef.current = null;
adoptScrollRef.current = false;
}
const deltaSeconds = frameDeltaSeconds(ts - lastTsRef.current);
lastTsRef.current = ts;
let next = posRef.current;
if (pendingDeltaRef.current !== 0) {
next += pendingDeltaRef.current;
pendingDeltaRef.current = 0;
// an explicit nudge overrides an eased jump in progress
catchUpTargetRef.current = null;
}
if (catchUpTargetRef.current !== null) {
next = easeCatchUp(next, catchUpTargetRef.current, deltaSeconds);
if (hasArrived(next, catchUpTargetRef.current)) {
next = catchUpTargetRef.current;
catchUpTargetRef.current = null;
}
} else if (runningRef.current) {
const result = advance(next, speedPxSecRef.current, deltaSeconds, maxScrollRef.current);
next = result.position;
if (result.atEnd) {
runningRef.current = false;
setIsRunning(false);
setAtEnd(true);
}
}
posRef.current = clamp(next, 0, Math.max(maxScrollRef.current, 0));
// a fractional value is intentional, the browser rounds it for us while we
// keep the remainder, which is what makes slow speeds move at all
el.scrollTop = posRef.current;
const hasWork =
runningRef.current ||
catchUpTargetRef.current !== null ||
pendingDeltaRef.current !== 0 ||
adoptScrollRef.current;
frameRef.current = hasWork ? requestAnimationFrame((next) => tickRef.current(next)) : null;
};
// stop the loop when the view goes away
useEffect(() => {
return () => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
}, []);
// keep the derived pixel speed in sync with the lines per minute the user sees
useEffect(() => {
speedRef.current = speed;
speedPxSecRef.current = linesPerMinuteToPxPerSecond(speed, lineHeightRef.current);
}, [speed]);
// the configured speed is the starting point, live changes win afterwards
useEffect(() => {
setSpeed(clampSpeed(initialSpeed));
}, [initialSpeed]);
// remeasure whenever the document or the viewport changes shape
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, contentKey]);
// webfonts land after first paint and reflow the whole document
useEffect(() => {
let cancelled = false;
void document.fonts?.ready.then(() => {
if (!cancelled) measure();
});
return () => {
cancelled = true;
};
}, [measure]);
// requestAnimationFrame is suspended while the tab is hidden, so the first
// timestamp on return is stale. Reset the baseline rather than jump.
useEffect(() => {
const onVisibilityChange = () => {
lastTsRef.current = performance.now();
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
}, []);
// autoplay once, on mount
useEffect(() => {
if (!autoplay) return;
runningRef.current = true;
setIsRunning(true);
ensureLoop();
// eslint-disable-next-line react-hooks/exhaustive-deps -- autoplay is a starting condition, not a live one
}, []);
// follow the loaded event
useEffect(() => {
if (!followLoaded || followLocked || !selectedEventId) return;
const scroller = scrollerRef.current;
const target = blockRefs.current.get(selectedEventId);
if (!scroller || !target) return;
// same arithmetic as useFollowComponent, but we hand the result to our own
// loop instead of calling scrollTo
const offset = (scroller.clientHeight * readingLinePos) / 100;
const top = target.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop - offset;
catchUpTargetRef.current = clamp(top, 0, Math.max(maxScrollRef.current, 0));
setAtEnd(false);
ensureLoop();
}, [selectedEventId, followLoaded, followLocked, readingLinePos, contentKey, ensureLoop]);
const lockFollow = useMemo(() => throttle(() => setFollowLocked(true), FOLLOW_LOCK_THROTTLE), []);
/**
* Any gesture which could have moved the scroller.
* We do not react to the scroll event itself because it also fires for our own
* writes, and the two are indistinguishable.
*/
const handleUserScroll = useCallback(() => {
adoptScrollRef.current = true;
ensureLoop();
if (followLoaded) {
lockFollow();
}
}, [ensureLoop, followLoaded, lockFollow]);
const registerBlock = useCallback(
(id: string) => (element: HTMLElement | null) => {
// entry ids are not guaranteed to be valid CSS selectors, so we keep a map
// rather than reaching for querySelector
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) {
// nothing left to read, rewinding is the only sensible way to resume
return;
}
runningRef.current = true;
setIsRunning(true);
setAtEnd(false);
ensureLoop();
};
const pause = () => {
runningRef.current = false;
setIsRunning(false);
};
return {
play,
pause,
togglePlay: () => (runningRef.current ? pause() : play()),
nudge: (lines: number) => {
pendingDeltaRef.current += lines * lineHeightRef.current;
setAtEnd(false);
ensureLoop();
},
page: (direction: 1 | -1) => {
const scroller = scrollerRef.current;
if (!scroller) return;
const distance = scroller.clientHeight * PAGE_FRACTION * direction;
catchUpTargetRef.current = clamp(posRef.current + distance, 0, Math.max(maxScrollRef.current, 0));
setAtEnd(false);
ensureLoop();
},
changeSpeed: (delta: number) => setSpeed((current) => clampSpeed(current + delta)),
rewind: (alsoPause = false) => {
catchUpTargetRef.current = 0;
if (alsoPause) pause();
setAtEnd(false);
ensureLoop();
},
jumpToEnd: () => {
catchUpTargetRef.current = Math.max(maxScrollRef.current, 0);
ensureLoop();
},
reengageFollow: () => setFollowLocked(false),
};
}, [ensureLoop]);
return {
scrollerRef,
contentRef,
registerBlock,
handleUserScroll,
controller,
isRunning,
speed,
followLocked,
atEnd,
};
}
+14 -1
View File
@@ -100,7 +100,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
submenu: [
{
label: 'New project...',
click: () => redirectWindow('/editor?settings=project__manage&new=true'),
click: () => redirectWindow('/editor?settings=project__create'),
},
{
label: 'Load...',
@@ -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`),
@@ -202,6 +203,18 @@ function makeSettingsMenu(redirectWindow) {
label: 'View settings',
click: () => redirectWindow('/editor?settings=settings__view'),
},
{
label: 'Custom views',
click: () => redirectWindow('/editor?settings=settings__custom-views'),
},
{
label: 'MCP Server',
click: () => redirectWindow('/editor?settings=settings__mcp'),
},
{
label: 'Server port',
click: () => redirectWindow('/editor?settings=settings__port'),
},
],
},
{
+10
View File
@@ -48,6 +48,16 @@ test.describe('test view navigation feature', () => {
await expect(page).toHaveURL('/timer');
});
test('Teleprompter', async ({ page }) => {
// note: openNavigationMenu presses Space, which the teleprompter claims for
// playback. Every test starts from the timer view, so reaching it is fine,
// but a test which navigates *away* from it must click the nav button.
await openNavigationMenu(page);
await page.getByRole('button', { name: 'Teleprompter' }).click();
page.locator('data-testid=teleprompter-view');
await expect(page).toHaveURL('/teleprompter');
});
test('not-found', async ({ page }) => {
await page.goto('/not-found');
+127
View File
@@ -0,0 +1,127 @@
import { type Page, expect, test } from '@playwright/test';
/**
* The note field is used as the script source throughout: every event in the
* test fixture has one, which keeps these tests independent of how custom field
* keys happen to be spelled in the fixture.
*/
const teleprompterUrl = '/teleprompter?script=note';
function scroller(page: Page) {
return page.getByTestId('teleprompter-scroller');
}
function scrollTop(page: Page) {
return scroller(page).evaluate((element) => element.scrollTop);
}
test.describe('teleprompter', () => {
test('shows the script from the selected source', async ({ page }) => {
await page.goto(teleprompterUrl);
await expect(page.getByTestId('teleprompter-view')).toBeVisible();
await expect(scroller(page)).toBeVisible();
// the fixture uses cue style notes, the first event is Albania
await expect(page.getByText('SF1.01', { exact: true })).toBeVisible();
});
test('asks for a script source when none is selected', async ({ page }) => {
await page.goto('/teleprompter');
await expect(page.getByTestId('teleprompter-view')).toBeVisible();
await expect(page.getByText('Select which field holds the script in the view options')).toBeVisible();
});
test('space starts and stops the scroll', async ({ page }) => {
await page.goto(teleprompterUrl);
await expect(scroller(page)).toBeVisible();
expect(await scrollTop(page)).toBe(0);
// run it fast so the movement is unambiguous within the wait
await page.keyboard.press('ArrowRight');
await page.keyboard.press('Space');
await page.waitForTimeout(1000);
const whileRunning = await scrollTop(page);
expect(whileRunning).toBeGreaterThan(0);
await page.keyboard.press('Space');
await page.waitForTimeout(200);
const afterPause = await scrollTop(page);
await page.waitForTimeout(500);
expect(await scrollTop(page)).toBe(afterPause);
});
test('space drives playback instead of opening the navigation menu', async ({ page }) => {
// the navigation menu binds Space globally, the teleprompter claims it back
await page.goto('/timer');
await page.mouse.move(60, 60);
await page.keyboard.press('Space');
await expect(page.getByRole('dialog')).toBeVisible();
await page.goto(teleprompterUrl);
await expect(scroller(page)).toBeVisible();
await page.mouse.move(60, 60);
await page.keyboard.press('Space');
await expect(page.getByRole('dialog')).toHaveCount(0);
// and the claim is released when the view goes away
await page.goto('/timer');
await page.mouse.move(60, 60);
await page.keyboard.press('Space');
await expect(page.getByRole('dialog')).toBeVisible();
});
test('arrow keys nudge and home rewinds', async ({ page }) => {
await page.goto(teleprompterUrl);
await expect(scroller(page)).toBeVisible();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(300);
expect(await scrollTop(page)).toBeGreaterThan(0);
await page.keyboard.press('Home');
await page.waitForTimeout(600);
expect(await scrollTop(page)).toBe(0);
});
test('arrow keys change the speed', async ({ page }) => {
await page.goto(teleprompterUrl);
await expect(scroller(page)).toBeVisible();
const readout = page.getByTestId('teleprompter-speed');
const before = Number(await readout.innerText().then((text) => text.replace(/\D/g, '')));
await page.keyboard.press('ArrowRight');
const faster = Number(await readout.innerText().then((text) => text.replace(/\D/g, '')));
expect(faster).toBeGreaterThan(before);
await page.keyboard.press('ArrowLeft');
const slower = Number(await readout.innerText().then((text) => text.replace(/\D/g, '')));
expect(slower).toBe(before);
});
test('f mirrors the view for a beam splitter rig', async ({ page }) => {
await page.goto(teleprompterUrl);
await expect(scroller(page)).toBeVisible();
const view = page.getByTestId('teleprompter-view');
await page.keyboard.press('f');
// a horizontal flip is a negative x scale in the computed matrix
const transform = await view.evaluate((element) => getComputedStyle(element).transform);
expect(transform.startsWith('matrix(-1')).toBe(true);
});
test('honours the flip and reading line params', async ({ page }) => {
await page.goto('/teleprompter?script=note&flipV=true&readingLine=arrows');
const view = page.getByTestId('teleprompter-view');
const transform = await view.evaluate((element) => getComputedStyle(element).transform);
// a vertical flip leaves x positive and makes the y scale negative
expect(transform).toMatch(/^matrix\(1, 0, 0, -1/);
});
});
@@ -11,6 +11,7 @@ export enum OntimeView {
StudioClock = 'studio',
Countdown = 'countdown',
ProjectInfo = 'info',
Teleprompter = 'teleprompter',
}
export type OntimeViewPresettable = Exclude<OntimeView, OntimeView.Editor>;