mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-13 19:33:46 +00:00
323b30ea68
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
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 { 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,
|
|
};
|
|
}
|