Files
musicmouse/web/src/hooks/usePlaybackClock.ts
Martin Bauer cc3db44c4e Add a ?pi=1 profile, and stop re-deriving search keys per keystroke
Two separate costs, both measured on musicdolphin (Pi 4, 1920x1080 kiosk), where the
app was burning ~70% of a core with nothing happening on screen.

Per-frame work. The ambient canvas repaints a full-screen gradient plus a particle
field every frame, and `usePlaybackClock` pushes a React setState per animation frame
into both PlayView and PlayerBar for the whole length of a track. `?pi=1` (lib/
lowPower.ts) makes those cheaper rather than switching them off: the canvas paints a
quarter of the pixels at 30fps with a bubble cap, and the clock renders ten times a
second - a progress bar advances one pixel every few hundred ms and its label has
one-second resolution, so nothing on screen can tell. Only the effects with no cheap
version actually go: the backdrop-filter glass blur and the decorative CSS loops.
Also drops a redundant full-canvas clearRect that the opaque gradient always covered.

Search. normalize() runs a Unicode NFD decomposition, and albumMatches/songMatches
called it on every album title and every track title on every keystroke - 4969 of them
for a track search, whose answer cannot change until the library does. buildSearchIndex
does it once per library payload; a keystroke is now String.includes over strings that
already exist. On the real library that is 33ms -> 3.4ms for an eight-letter track
query on a laptop, and this runs on a Pi. The same index partitions albums by shelf and
pre-sorts each shelf's categories, which App and BrowseView were deriving separately
from the same data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 13:15:23 +02:00

44 lines
1.7 KiB
TypeScript

/** 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;
}