/** * The replay transport. * * A recorded rollout is played back on the timings the model actually took. * That is not decoration: "the second guess took four seconds and 900 tokens" * is one of the few things on this page an executive can feel rather than read. * * So the hard rule here is that **no code path invents a duration silently**. * When a step's `call.durationMs` is null the player falls back to * `FALLBACK_STEP_MS` and says so — `timingIsReal` goes false and `invented[i]` * marks the step — so the UI can label the timeline as approximate instead of * quietly presenting a made-up number as a measurement. * * Playback always starts PAUSED. A board that animates itself the moment the * page loads has already played its best moment to a visitor who was still * reading the headline. */ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import type { DemoStep } from './types'; /** 1x, 2x, 4x, or straight to the end. */ export type PlaybackSpeed = 1 | 2 | 4 | 'instant'; export const PLAYBACK_SPEEDS: readonly PlaybackSpeed[] = [1, 2, 4, 'instant']; export function isPlaybackSpeed(value: unknown): value is PlaybackSpeed { return value === 1 || value === 2 || value === 4 || value === 'instant'; } /** * Dwell for a step whose real duration was not recorded. Exported and named so * that when it appears on screen it can be labelled as the estimate it is. */ export const FALLBACK_STEP_MS = 900; /** * How often the fractional progress within a step is pushed into React state. * * Not every frame, on purpose: `progress` re-renders the whole demo page, and * at 60 Hz on a phone that is the difference between a smooth board and a warm * one. ~15 Hz is plenty as long as whatever consumes it has a CSS transition on * the property it drives — give your progress bar `transition-[width] * duration-1` and the gaps disappear. */ const PROGRESS_TICK_MS = 66; const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; function subscribeReducedMotion(onChange: () => void): () => void { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { return () => undefined; } const query = window.matchMedia(REDUCED_MOTION_QUERY); query.addEventListener('change', onChange); return () => query.removeEventListener('change', onChange); } function readReducedMotion(): boolean { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false; return window.matchMedia(REDUCED_MOTION_QUERY).matches; } /** Live `prefers-reduced-motion`. Re-renders when the OS setting changes. */ export function usePrefersReducedMotion(): boolean { return useSyncExternalStore(subscribeReducedMotion, readReducedMotion, () => false); } /** The recorded duration, or `null` when the trace did not carry a usable one. */ function recordedDurationMs(step: DemoStep | undefined): number | null { const recorded = step?.call?.durationMs; if (recorded === null || recorded === undefined) return null; // A zero or negative duration is a recording artefact, not a measurement. if (!Number.isFinite(recorded) || recorded <= 0) return null; return recorded; } export interface PlayerOptions { /** Where to start — typically the `step` URL param. Clamped. */ initialIndex?: number; initialSpeed?: PlaybackSpeed; /** Fired on every index change, including seeks. Used to sync the permalink. */ onIndexChange?: (index: number) => void; /** Override the invented dwell. Still reported as invented. */ fallbackStepMs?: number; } export interface Player { index: number; /** `undefined` only when there are no steps at all. */ step: DemoStep | undefined; stepCount: number; isPlaying: boolean; speed: PlaybackSpeed; /** 0..1 through the current step. Pinned to 0 under reduced motion. */ progress: number; elapsedMs: number; totalMs: number; atStart: boolean; atEnd: boolean; /** The OS setting, surfaced so surfaces can skip their own animations too. */ reducedMotion: boolean; /** False when ANY step's dwell was invented. Label the timeline when false. */ timingIsReal: boolean; /** Per step: was this dwell invented? */ invented: readonly boolean[]; /** The dwell actually used per step, real or invented. */ durationsMs: readonly number[]; fallbackStepMs: number; play: () => void; pause: () => void; toggle: () => void; /** Jump to a step. Does NOT change play/pause — a scrubber drag keeps playing. */ seek: (index: number) => void; /** Manual stepping pauses: you asked to look at this one. */ next: () => void; prev: () => void; restart: () => void; setSpeed: (speed: PlaybackSpeed) => void; } export function usePlayer( steps: readonly DemoStep[], options: PlayerOptions = {}, ): Player { const fallbackStepMs = options.fallbackStepMs ?? FALLBACK_STEP_MS; const stepCount = steps.length; const lastIndex = Math.max(0, stepCount - 1); const reducedMotion = usePrefersReducedMotion(); const [index, setIndexState] = useState(() => clamp(options.initialIndex ?? 0, 0, Math.max(0, steps.length - 1)), ); const [isPlaying, setIsPlaying] = useState(false); const [speed, setSpeedState] = useState(options.initialSpeed ?? 1); const [progress, setProgress] = useState(0); const indexRef = useRef(index); const elapsedRef = useRef(0); const lastProgressPushRef = useRef(0); // Held in a ref so changing the callback never restarts the animation loop. const onIndexChangeRef = useRef(options.onIndexChange); onIndexChangeRef.current = options.onIndexChange; const durationsMs = useMemo( () => steps.map((step) => recordedDurationMs(step) ?? fallbackStepMs), [steps, fallbackStepMs], ); const invented = useMemo(() => steps.map((step) => recordedDurationMs(step) === null), [steps]); const timingIsReal = useMemo(() => !invented.includes(true), [invented]); const cumulativeMs = useMemo(() => { let running = 0; return durationsMs.map((duration) => { const start = running; running += duration; return start; }); }, [durationsMs]); const totalMs = useMemo(() => durationsMs.reduce((sum, d) => sum + d, 0), [durationsMs]); const dwellAt = useCallback( (at: number) => durationsMs[at] ?? fallbackStepMs, [durationsMs, fallbackStepMs], ); const commitIndex = useCallback((next: number) => { if (indexRef.current === next) return; indexRef.current = next; setIndexState(next); onIndexChangeRef.current?.(next); }, []); // A new steps array means a new run. Rewind rather than leaving the transport // pointing at step 7 of a rollout that only has four turns. const stepsRef = useRef(steps); useEffect(() => { if (stepsRef.current === steps) return; stepsRef.current = steps; elapsedRef.current = 0; setIsPlaying(false); setProgress(0); commitIndex(0); }, [steps, commitIndex]); const seek = useCallback( (to: number) => { elapsedRef.current = 0; setProgress(0); commitIndex(clamp(to, 0, Math.max(0, stepsRef.current.length - 1))); }, [commitIndex], ); const play = useCallback(() => { if (stepsRef.current.length === 0) return; // Pressing play on the final step replays from the top; the alternative is // a button that visibly does nothing. if (indexRef.current >= stepsRef.current.length - 1) { elapsedRef.current = 0; setProgress(0); commitIndex(0); } setIsPlaying(true); }, [commitIndex]); const pause = useCallback(() => setIsPlaying(false), []); const toggle = useCallback(() => { if (isPlaying) pause(); else play(); }, [isPlaying, pause, play]); const next = useCallback(() => { setIsPlaying(false); seek(indexRef.current + 1); }, [seek]); const prev = useCallback(() => { setIsPlaying(false); seek(indexRef.current - 1); }, [seek]); const restart = useCallback(() => { setIsPlaying(false); seek(0); }, [seek]); const setSpeed = useCallback((nextSpeed: PlaybackSpeed) => setSpeedState(nextSpeed), []); useEffect(() => { if (!isPlaying || stepCount === 0) return; let raf = 0; let previous = performance.now(); const frame = (now: number): void => { // `instant` is expressed as infinite elapsed time rather than an infinite // rate: `(now - previous) * Infinity` is NaN on the very first frame, // where `now === previous`, and NaN would freeze the transport forever. const gained = speed === 'instant' ? Number.POSITIVE_INFINITY : (now - previous) * speed; previous = now; const startIndex = indexRef.current; let elapsed = elapsedRef.current + gained; let at = startIndex; while (at < lastIndex && elapsed >= dwellAt(at)) { elapsed -= dwellAt(at); at += 1; } if (at >= lastIndex && elapsed >= dwellAt(lastIndex)) { elapsedRef.current = dwellAt(lastIndex); commitIndex(lastIndex); setProgress(1); setIsPlaying(false); return; // Run over. Deliberately not scheduling another frame. } elapsedRef.current = elapsed; if (at !== startIndex) { commitIndex(at); lastProgressPushRef.current = now; // Reduced motion still advances on real time — the content is not the // animation — but no fractional progress is emitted, so nothing on the // page is being driven frame by frame. setProgress(reducedMotion ? 0 : Math.min(1, elapsed / dwellAt(at))); } else if (!reducedMotion && now - lastProgressPushRef.current >= PROGRESS_TICK_MS) { lastProgressPushRef.current = now; setProgress(Math.min(1, elapsed / dwellAt(at))); } raf = requestAnimationFrame(frame); }; raf = requestAnimationFrame(frame); return () => cancelAnimationFrame(raf); }, [isPlaying, speed, stepCount, lastIndex, dwellAt, reducedMotion, commitIndex]); const elapsedMs = (cumulativeMs[index] ?? 0) + progress * dwellAt(index); return { index, step: steps[index], stepCount, isPlaying, speed, progress, elapsedMs, totalMs, atStart: index === 0, atEnd: stepCount === 0 || index >= lastIndex, reducedMotion, timingIsReal, invented, durationsMs, fallbackStepMs, play, pause, toggle, seek, next, prev, restart, setSpeed, }; } function clamp(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; return Math.min(max, Math.max(min, Math.trunc(value))); }