diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx index 34d21b950..0669c288c 100644 --- a/apps/client/src/AppRouter.tsx +++ b/apps/client/src/AppRouter.tsx @@ -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() { } /> + + + + + } + /> {/*/!* Protected Routes *!/*/} } /> } /> @@ -165,6 +175,7 @@ const PresetViewMap: Record = { [OntimeView.StudioClock]: StudioClock, [OntimeView.Countdown]: Countdown, [OntimeView.ProjectInfo]: ProjectInfo, + [OntimeView.Teleprompter]: Teleprompter, }; /** diff --git a/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx index 4545333bb..7bf1f9181 100644 --- a/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx +++ b/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx @@ -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 }, diff --git a/apps/client/src/common/stores/viewHotkeys.ts b/apps/client/src/common/stores/viewHotkeys.ts new file mode 100644 index 000000000..0d74754ce --- /dev/null +++ b/apps/client/src/common/stores/viewHotkeys.ts @@ -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((set) => ({ + spaceClaimed: false, + claimSpace: () => set({ spaceClaimed: true }), + releaseSpace: () => set({ spaceClaimed: false }), +})); diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.tsx b/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.tsx index 4eb5812b8..f2977ddb7 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.tsx @@ -26,6 +26,7 @@ const targetOptions: SelectOption[] = [ { 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'; diff --git a/apps/client/src/features/sharing/GenerateLinkFormExport.tsx b/apps/client/src/features/sharing/GenerateLinkFormExport.tsx index 5b5564c4d..21337ff6f 100644 --- a/apps/client/src/features/sharing/GenerateLinkFormExport.tsx +++ b/apps/client/src/features/sharing/GenerateLinkFormExport.tsx @@ -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: '<>', label: 'Companion' }, ...urlPresetData.map((preset) => ({ value: `preset-${preset.alias}`, diff --git a/apps/client/src/viewerConfig.ts b/apps/client/src/viewerConfig.ts index 88388853b..ddde17547 100644 --- a/apps/client/src/viewerConfig.ts +++ b/apps/client/src/viewerConfig.ts @@ -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 diff --git a/apps/client/src/views/teleprompter/Teleprompter.scss b/apps/client/src/views/teleprompter/Teleprompter.scss new file mode 100644 index 000000000..7b99deeed --- /dev/null +++ b/apps/client/src/views/teleprompter/Teleprompter.scss @@ -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; +} diff --git a/apps/client/src/views/teleprompter/Teleprompter.tsx b/apps/client/src/views/teleprompter/Teleprompter.tsx new file mode 100644 index 000000000..030540423 --- /dev/null +++ b/apps/client/src/views/teleprompter/Teleprompter.tsx @@ -0,0 +1,180 @@ +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 { 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 } 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 ; + } + + if (status === 'error') { + return ; + } + + return ; +} + +function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterData) { + const options = useTeleprompterOptions(); + const selectedEventId = useSelectedEventId(); + + 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'; + + 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 ( +
+ + + {!hasScriptSource ? ( + + ) : blocks.length === 0 ? ( + + ) : ( + <> +
+
+ {blocks.map((block) => ( + + ))} +
+
+ + + + + + )} + + {showHelp && } +
+ ); +} diff --git a/apps/client/src/views/teleprompter/__tests__/teleprompter.keymap.test.ts b/apps/client/src/views/teleprompter/__tests__/teleprompter.keymap.test.ts new file mode 100644 index 000000000..a0afa5214 --- /dev/null +++ b/apps/client/src/views/teleprompter/__tests__/teleprompter.keymap.test.ts @@ -0,0 +1,104 @@ +import { resolveTeleprompterAction, type TeleprompterKeyEvent } from '../teleprompter.keymap'; +import { FONT_SCALE_STEP } from '../teleprompter.scroll'; + +function makeEvent(overrides: Partial): 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(); + }); +}); diff --git a/apps/client/src/views/teleprompter/__tests__/teleprompter.options.test.ts b/apps/client/src/views/teleprompter/__tests__/teleprompter.options.test.ts new file mode 100644 index 000000000..60521f804 --- /dev/null +++ b/apps/client/src/views/teleprompter/__tests__/teleprompter.options.test.ts @@ -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'); + }); +}); diff --git a/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts b/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts new file mode 100644 index 000000000..640f2c628 --- /dev/null +++ b/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts @@ -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); + }); +}); diff --git a/apps/client/src/views/teleprompter/__tests__/teleprompter.utils.test.ts b/apps/client/src/views/teleprompter/__tests__/teleprompter.utils.test.ts new file mode 100644 index 000000000..3c9a87e14 --- /dev/null +++ b/apps/client/src/views/teleprompter/__tests__/teleprompter.utils.test.ts @@ -0,0 +1,176 @@ +import { type CustomFields, type OntimeEntry, type Rundown, SupportedEntry } from 'ontime-types'; + +import type { RundownMetadata, RundownMetadataObject } from '../../../common/utils/rundownMetadata'; +import { buildScript } from '../teleprompter.utils'; + +function makeEvent(id: string, overrides: Partial = {}): 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 { + 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> = {}): 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(''); + }); + }); +}); diff --git a/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx b/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx new file mode 100644 index 000000000..2c8d8ceab --- /dev/null +++ b/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx @@ -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 ( +
+ + + + +
+ {speed} + lpm +
+ + + + + + {followLocked && ( + + )} + + +
+ ); +} diff --git a/apps/client/src/views/teleprompter/help-overlay/HelpOverlay.tsx b/apps/client/src/views/teleprompter/help-overlay/HelpOverlay.tsx new file mode 100644 index 000000000..844f9c0d3 --- /dev/null +++ b/apps/client/src/views/teleprompter/help-overlay/HelpOverlay.tsx @@ -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 ( +
+
+
Controls
+
+ {shortcuts.map(({ keys, action }) => ( +
+
{keys}
+
{action}
+
+ ))} +
+
+ Foot pedals and hand controllers which emit these keys work without any setup. +
+ +
+
+ ); +} diff --git a/apps/client/src/views/teleprompter/reading-line/ReadingLine.tsx b/apps/client/src/views/teleprompter/reading-line/ReadingLine.tsx new file mode 100644 index 000000000..9b231489b --- /dev/null +++ b/apps/client/src/views/teleprompter/reading-line/ReadingLine.tsx @@ -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 &&
} + {variant !== 'none' && ( +
+ {variant === 'arrows' && ( + <> + + + + )} +
+ )} + + ); +} diff --git a/apps/client/src/views/teleprompter/script-block/ScriptBlock.tsx b/apps/client/src/views/teleprompter/script-block/ScriptBlock.tsx new file mode 100644 index 000000000..562aaba6b --- /dev/null +++ b/apps/client/src/views/teleprompter/script-block/ScriptBlock.tsx @@ -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 ( +
+ {block.groupTitle &&
{block.groupTitle}
} + {block.heading &&

{block.heading}

} + {/* the script is user data, it is rendered as text and never as markup */} +

+ {block.text} +

+
+ ); +} diff --git a/apps/client/src/views/teleprompter/teleprompter.keymap.ts b/apps/client/src/views/teleprompter/teleprompter.keymap.ts new file mode 100644 index 000000000..e1f7a4828 --- /dev/null +++ b/apps/client/src/views/teleprompter/teleprompter.keymap.ts @@ -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; +} diff --git a/apps/client/src/views/teleprompter/teleprompter.options.ts b/apps/client/src/views/teleprompter/teleprompter.options.ts new file mode 100644 index 000000000..533320b99 --- /dev/null +++ b/apps/client/src/views/teleprompter/teleprompter.options.ts @@ -0,0 +1,278 @@ +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, for use with a beam splitter rig. Toggled live with F', + 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(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]); +} diff --git a/apps/client/src/views/teleprompter/teleprompter.scroll.ts b/apps/client/src/views/teleprompter/teleprompter.scroll.ts new file mode 100644 index 000000000..bd0064fd4 --- /dev/null +++ b/apps/client/src/views/teleprompter/teleprompter.scroll.ts @@ -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; +} diff --git a/apps/client/src/views/teleprompter/teleprompter.types.ts b/apps/client/src/views/teleprompter/teleprompter.types.ts new file mode 100644 index 000000000..e2f06116c --- /dev/null +++ b/apps/client/src/views/teleprompter/teleprompter.types.ts @@ -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-' | '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; +}; diff --git a/apps/client/src/views/teleprompter/teleprompter.utils.ts b/apps/client/src/views/teleprompter/teleprompter.utils.ts new file mode 100644 index 000000000..fe4c30576 --- /dev/null +++ b/apps/client/src/views/teleprompter/teleprompter.utils.ts @@ -0,0 +1,116 @@ +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; +} + +/** + * 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; +} diff --git a/apps/client/src/views/teleprompter/useTeleprompterControls.ts b/apps/client/src/views/teleprompter/useTeleprompterControls.ts new file mode 100644 index 000000000..5d827d785 --- /dev/null +++ b/apps/client/src/views/teleprompter/useTeleprompterControls.ts @@ -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); + }, []); +} diff --git a/apps/client/src/views/teleprompter/useTeleprompterData.ts b/apps/client/src/views/teleprompter/useTeleprompterData.ts new file mode 100644 index 000000000..ed9f3be96 --- /dev/null +++ b/apps/client/src/views/teleprompter/useTeleprompterData.ts @@ -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 { + const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata(); + const { data: customFields, status: customFieldStatus } = useCustomFields(); + + return { + data: { + rundown, + rundownMetadata, + customFields, + }, + status: aggregateQueryStatus([rundownStatus, customFieldStatus]), + }; +} diff --git a/apps/client/src/views/teleprompter/useTeleprompterScroll.ts b/apps/client/src/views/teleprompter/useTeleprompterScroll.ts new file mode 100644 index 000000000..81a1ca54b --- /dev/null +++ b/apps/client/src/views/teleprompter/useTeleprompterScroll.ts @@ -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(null); + const contentRef = useRef(null); + const blockRefs = useRef(new Map()); + + // authoritative, sub-pixel scroll position + const posRef = useRef(0); + const lastTsRef = useRef(0); + const frameRef = useRef(null); + const runningRef = useRef(false); + const speedPxSecRef = useRef(0); + const speedRef = useRef(initialSpeed); + const lineHeightRef = useRef(0); + const maxScrollRef = useRef(0); + const catchUpTargetRef = useRef(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, + }; +} diff --git a/apps/electron/src/menu/applicationMenu.js b/apps/electron/src/menu/applicationMenu.js index d11a042f2..e7218decb 100644 --- a/apps/electron/src/menu/applicationMenu.js +++ b/apps/electron/src/menu/applicationMenu.js @@ -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`), diff --git a/e2e/tests/002-view-navigation.spec.ts b/e2e/tests/002-view-navigation.spec.ts index e970e162f..847273eda 100644 --- a/e2e/tests/002-view-navigation.spec.ts +++ b/e2e/tests/002-view-navigation.spec.ts @@ -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'); diff --git a/e2e/tests/features/215-teleprompter.spec.ts b/e2e/tests/features/215-teleprompter.spec.ts new file mode 100644 index 000000000..821795b08 --- /dev/null +++ b/e2e/tests/features/215-teleprompter.spec.ts @@ -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/); + }); +}); diff --git a/packages/types/src/definitions/core/UrlPreset.type.ts b/packages/types/src/definitions/core/UrlPreset.type.ts index 57f070225..fbbbb38e8 100644 --- a/packages/types/src/definitions/core/UrlPreset.type.ts +++ b/packages/types/src/definitions/core/UrlPreset.type.ts @@ -11,6 +11,7 @@ export enum OntimeView { StudioClock = 'studio', Countdown = 'countdown', ProjectInfo = 'info', + Teleprompter = 'teleprompter', } export type OntimeViewPresettable = Exclude;