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(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 [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, }; }