mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-18 05:34:09 +00:00
f806244586
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
339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|