/** A smooth playback position, from a position that only arrives twice a second. Driving a progress bar straight off the websocket visibly steps. This interpolates between frames on every animation frame and re-seeds whenever a real one lands, which also gives beat-synced animation the frame-accurate clock it will need later. */ import { useEffect, useRef, useState } from "react"; import { PLAYBACK_CLOCK_FPS } from "../lib/lowPower"; /** A render budget, not a timer - rAF still drives the loop, this only decides which * frames are allowed to push state. The 1ms slack keeps a budget from landing just * inside a vsync interval and silently halving the rate it asked for. */ const MIN_RENDER_MS = PLAYBACK_CLOCK_FPS > 0 ? 1000 / PLAYBACK_CLOCK_FPS - 1 : 0; export function usePlaybackClock(position: number, playing: boolean): number { const [interpolated, setInterpolated] = useState(position); const anchor = useRef({ position, at: performance.now() }); // A new server frame is the truth; restart the interpolation from it. useEffect(() => { anchor.current = { position, at: performance.now() }; setInterpolated(position); }, [position]); useEffect(() => { if (!playing) return; let frame = 0; let lastRender = 0; const tick = (now: number) => { frame = requestAnimationFrame(tick); if (now - lastRender < MIN_RENDER_MS) return; lastRender = now; const { position: base, at } = anchor.current; setInterpolated(base + (performance.now() - at) / 1000); }; frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame); }, [playing]); return playing ? interpolated : position; }