mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 06:29:07 +00:00
refactor(teleprompter): drop code that was not earning its keep
Review pass over the view. Removed: estimateWordsPerLine and linesPerMinuteToWordsPerMinute, written for a words-per-minute readout that was never built; play and pause on the controller, which nothing outside the hook called; and three constants that were exported but never imported. The tick function was being reassigned to a ref on every render, which is a side effect during render. It only ever touched refs and state setters, so it is now a stable callback and the ref is gone. The synthetic contentKey string is replaced by the memoised blocks array it was standing in for. It was also a dependency of the ResizeObserver effect, which tore the observer down and rebuilt it for no gain: the observer already covers every reflow that changes the document. ScriptBlock was memoised but never actually memoising, because the parent built its ref callback inline and handed it a new identity every render. That also churned the follow map, unregistering and re-registering every block. The id is now bound inside the block against a stable callback. Tests: dropped six that asserted arithmetic identities or wrapped clamps rather than behaviour, and added three for branches that were untested, group titles across and back into a group, and a heading with no cue. The e2e rewind assertion waited 600ms for an eased scroll that needs about 900ms from a nudge and a second from the bottom of a long script; it now polls. The navigation menu assertion raced app hydration and now waits for the view, as the existing navigation tests do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
hasArrived,
|
||||
linesPerMinuteToPxPerSecond,
|
||||
} from './teleprompter.scroll';
|
||||
import type { TeleprompterController } from './teleprompter.types';
|
||||
import type { ScriptBlock, TeleprompterController } from './teleprompter.types';
|
||||
|
||||
/** how much of a screen a page jump moves */
|
||||
const PAGE_FRACTION = 0.85;
|
||||
@@ -25,8 +25,8 @@ interface UseTeleprompterScrollArgs {
|
||||
selectedEventId: string | null;
|
||||
/** percentage from the top of the screen */
|
||||
readingLinePos: number;
|
||||
/** changes whenever the rendered document changes, forcing a remeasure */
|
||||
contentKey: string;
|
||||
/** the rendered document, watched so a follow can retarget once blocks remount */
|
||||
blocks: ScriptBlock[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +49,7 @@ export function useTeleprompterScroll({
|
||||
followLoaded,
|
||||
selectedEventId,
|
||||
readingLinePos,
|
||||
contentKey,
|
||||
blocks,
|
||||
}: UseTeleprompterScrollArgs) {
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -67,47 +67,19 @@ export function useTeleprompterScroll({
|
||||
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) => {
|
||||
/**
|
||||
* Advances the scroll by one frame.
|
||||
*
|
||||
* Reads and writes only refs, so it never needs rebuilding: keeping it stable
|
||||
* is what lets the loop reschedule itself without going through the render.
|
||||
*/
|
||||
const tick = useCallback((timestamp: number) => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) {
|
||||
frameRef.current = null;
|
||||
@@ -123,8 +95,8 @@ export function useTeleprompterScroll({
|
||||
adoptScrollRef.current = false;
|
||||
}
|
||||
|
||||
const deltaSeconds = frameDeltaSeconds(ts - lastTsRef.current);
|
||||
lastTsRef.current = ts;
|
||||
const deltaSeconds = frameDeltaSeconds(timestamp - lastTsRef.current);
|
||||
lastTsRef.current = timestamp;
|
||||
|
||||
let next = posRef.current;
|
||||
|
||||
@@ -161,10 +133,42 @@ export function useTeleprompterScroll({
|
||||
catchUpTargetRef.current !== null ||
|
||||
pendingDeltaRef.current !== 0 ||
|
||||
adoptScrollRef.current;
|
||||
frameRef.current = hasWork ? requestAnimationFrame((next) => tickRef.current(next)) : null;
|
||||
};
|
||||
frameRef.current = hasWork ? requestAnimationFrame(tick) : null;
|
||||
}, []);
|
||||
|
||||
/** 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(tick);
|
||||
}, [tick]);
|
||||
|
||||
/** 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);
|
||||
}, []);
|
||||
|
||||
// stop the loop when the view goes away
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (frameRef.current !== null) {
|
||||
@@ -185,7 +189,8 @@ export function useTeleprompterScroll({
|
||||
setSpeed(clampSpeed(initialSpeed));
|
||||
}, [initialSpeed]);
|
||||
|
||||
// remeasure whenever the document or the viewport changes shape
|
||||
// the observer covers every reflow that matters, script edits included, so
|
||||
// nothing else needs to ask for a remeasure
|
||||
useEffect(() => {
|
||||
measure();
|
||||
|
||||
@@ -197,7 +202,7 @@ export function useTeleprompterScroll({
|
||||
observer.observe(scroller);
|
||||
observer.observe(content);
|
||||
return () => observer.disconnect();
|
||||
}, [measure, contentKey]);
|
||||
}, [measure]);
|
||||
|
||||
// webfonts land after first paint and reflow the whole document
|
||||
useEffect(() => {
|
||||
@@ -245,7 +250,7 @@ export function useTeleprompterScroll({
|
||||
catchUpTargetRef.current = clamp(top, 0, Math.max(maxScrollRef.current, 0));
|
||||
setAtEnd(false);
|
||||
ensureLoop();
|
||||
}, [selectedEventId, followLoaded, followLocked, readingLinePos, contentKey, ensureLoop]);
|
||||
}, [selectedEventId, followLoaded, followLocked, readingLinePos, blocks, ensureLoop]);
|
||||
|
||||
const lockFollow = useMemo(() => throttle(() => setFollowLocked(true), FOLLOW_LOCK_THROTTLE), []);
|
||||
|
||||
@@ -262,18 +267,15 @@ export function useTeleprompterScroll({
|
||||
}
|
||||
}, [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);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
// entry ids are not guaranteed to be valid CSS selectors, so the follow target
|
||||
// is looked up through this map rather than with querySelector
|
||||
const registerBlock = useCallback((id: string, element: HTMLElement | null) => {
|
||||
if (element) {
|
||||
blockRefs.current.set(id, element);
|
||||
} else {
|
||||
blockRefs.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const controller: TeleprompterController = useMemo(() => {
|
||||
const play = () => {
|
||||
@@ -293,8 +295,6 @@ export function useTeleprompterScroll({
|
||||
};
|
||||
|
||||
return {
|
||||
play,
|
||||
pause,
|
||||
togglePlay: () => (runningRef.current ? pause() : play()),
|
||||
nudge: (lines: number) => {
|
||||
pendingDeltaRef.current += lines * lineHeightRef.current;
|
||||
|
||||
Reference in New Issue
Block a user