Files
ontime/apps/client/src/views/teleprompter/useTeleprompterScroll.ts
T
Claude 323b30ea68 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
2026-08-13 16:42:07 +00:00

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 { ScriptBlock, 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;
/** the rendered document, watched so a follow can retarget once blocks remount */
blocks: ScriptBlock[];
}
/**
* 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,
blocks,
}: 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 [isRunning, setIsRunning] = useState(false);
const [speed, setSpeed] = useState(initialSpeed);
const [followLocked, setFollowLocked] = useState(false);
const [atEnd, setAtEnd] = useState(false);
/**
* 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;
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(timestamp - lastTsRef.current);
lastTsRef.current = timestamp;
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(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);
}, []);
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]);
// the observer covers every reflow that matters, script edits included, so
// nothing else needs to ask for a remeasure
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]);
// 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, blocks, 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]);
// 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 = () => {
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 {
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,
};
}