fix(visualizer): gate rAF loop on is-playing; one-shot redraws while idle (§E)

This commit is contained in:
daniel-c-harvey
2026-06-14 18:31:24 -04:00
parent 2d0a565765
commit c64455f2f2
@@ -154,6 +154,22 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
let cssHeight = 0; let cssHeight = 0;
let dpr = 1; let dpr = 1;
// ── ResizeObserver: one-shot redraw when the container changes while idle. ────
//
// While the rAF loop is running (playing), syncCanvasSize() catches resizes each
// frame. While idle (paused/stopped), we use a ResizeObserver instead — it fires
// only when the element actually changes size, which is far cheaper than a 60fps
// tick. On each observation we do a single one-shot redraw.
const resizeObserver = new ResizeObserver(() => {
if (!playback.isPlaying && !disposed) {
// Loop is not running; draw one still frame reflecting the new size.
redrawOnce();
}
// If the loop IS running, syncCanvasSize() inside frame() will catch it next
// tick — no action needed here.
});
resizeObserver.observe(canvas);
/** /**
* Sync the canvas backing store to its CSS size × devicePixelRatio so the draw * Sync the canvas backing store to its CSS size × devicePixelRatio so the draw
* is crisp on HiDPI without blurring. Returns true if a resize happened. * is crisp on HiDPI without blurring. Returns true if a resize happened.
@@ -293,36 +309,84 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
return v < 0 ? 0 : v > 1 ? 1 : v; return v < 0 ? 0 : v > 1 ? 1 : v;
} }
/** // ── rAF loop lifecycle (spec §E: cool when paused/backgrounded). ─────────────
* The animation loop. We always keep ONE rAF scheduled while not disposed so the //
* canvas stays correctly sized and a single still slice is shown when paused — // DESIGN: The loop runs ONLY while playing. When paused or stopped, no frames
* but we only redraw the moving content while playing. A backgrounded tab gets // are scheduled. The still slice stays correct via one-shot redraws triggered by
* rAF throttled by the browser automatically (spec §E "cool idle"); on top of // the handle methods (setZoom, refreshTheme, setDatum) and by ResizeObserver.
* that we skip the expensive redraw when not playing, so a paused/foregrounded //
* mix also stays cheap. // Start/stop contract:
*/ // startLoop() — schedules the first frame if not already running. Safe to call
let lastDrewWhilePaused = false; // redundantly; the rafId guard prevents double-loops.
function frame(): void { // stopLoop() — cancels any pending frame. The current frame callback will see
if (disposed) return; // playback.isPlaying === false and will NOT reschedule itself, so
// this is belt-and-suspenders for the dispose() path.
// redrawOnce() — draw one still frame synchronously (no rAF scheduling). Used
// by setZoom/refreshTheme/setDatum/ResizeObserver while idle.
const resized = syncCanvasSize(); /**
* Draw one still frame immediately, without scheduling a new rAF. Syncs the
* canvas size first so zoom/theme/datum/resize changes are reflected correctly
* even when the loop is not running.
*/
function redrawOnce(): void {
if (disposed) return;
syncCanvasSize();
draw();
}
/**
* Start the rAF loop. No-op if already running or disposed — the rafId guard
* ensures at most one loop is live at any time.
*/
function startLoop(): void {
if (disposed || rafId !== null) return;
rafId = requestAnimationFrame(frame);
}
/**
* Stop the rAF loop. Safe to call when already stopped.
*/
function stopLoop(): void {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
/**
* The animation loop. Runs only while playing. Each frame:
* 1. Syncs the canvas backing-store size (cheap no-op when nothing changed).
* 2. Redraws the scrolling waveform with the current playback position.
* 3. Reschedules itself — unless playback has stopped since this frame was
* queued, in which case it draws one final still frame and exits the loop.
*
* A backgrounded tab gets rAF throttled by the browser automatically. On top of
* that, the loop does not run at all when paused — so a foregrounded-but-paused
* mix burns no rAF budget (spec §E acceptance criterion).
*/
function frame(): void {
if (disposed) {
rafId = null;
return;
}
syncCanvasSize();
draw();
if (playback.isPlaying) { if (playback.isPlaying) {
// Playback position is pushed in from Blazor each tick; redraw every frame // Still playing — schedule the next frame.
// so the scroll is smooth between ticks (position is interpolated upstream). rafId = requestAnimationFrame(frame);
draw(); } else {
lastDrewWhilePaused = false; // Playback stopped between the time this frame was queued and now.
} else if (resized || !lastDrewWhilePaused) { // We already drew the final still frame above; exit the loop.
// Paused/stopped: draw the still slice once (and again only if the canvas rafId = null;
// resized). Holding the scroll on pause falls out of position being held. }
draw();
lastDrewWhilePaused = true;
} }
rafId = requestAnimationFrame(frame); // Kick off one still frame on creation so the canvas is not blank while idle
} // before the first play command arrives.
redrawOnce();
rafId = requestAnimationFrame(frame);
return { return {
setDatum(samplesBase64: string, durationSeconds: number): void { setDatum(samplesBase64: string, durationSeconds: number): void {
@@ -337,30 +401,48 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// samplesPerSecond from the ACTUAL datum length — never assume 2048. // samplesPerSecond from the ACTUAL datum length — never assume 2048.
samplesPerSecond: samples.length / durationSeconds, samplesPerSecond: samples.length / durationSeconds,
}; };
lastDrewWhilePaused = false; // force a repaint of the new datum // New datum changes what is drawn — refresh the still slice immediately.
// If playing, the running loop will pick it up on the next frame automatically.
if (!playback.isPlaying) redrawOnce();
}, },
setPlayback(positionSeconds: number, isPlaying: boolean): void { setPlayback(positionSeconds: number, isPlaying: boolean): void {
const wasPlaying = playback.isPlaying;
playback = { positionSeconds, isPlaying }; playback = { positionSeconds, isPlaying };
if (isPlaying && !wasPlaying) {
// Transition: paused/stopped → playing. Start the rAF loop.
startLoop();
} else if (!isPlaying && wasPlaying) {
// Transition: playing → paused/stopped. The current in-flight frame
// will draw the final still position and exit the loop on its own
// (frame() checks playback.isPlaying before rescheduling). We do NOT
// call stopLoop() here — that would cancel the in-flight frame before
// it draws, leaving a stale or blank canvas. Let the frame run out.
}
// If isPlaying unchanged (position-only update), the running loop (if any)
// will redraw on the next frame automatically; no action needed.
}, },
setZoom(seconds: number): void { setZoom(seconds: number): void {
// Clamp into the supported span so a stray value can't break the math. // Clamp into the supported span so a stray value can't break the math.
visibleSeconds = Math.min(MAX_VISIBLE_SECONDS, Math.max(MIN_VISIBLE_SECONDS, seconds)); visibleSeconds = Math.min(MAX_VISIBLE_SECONDS, Math.max(MIN_VISIBLE_SECONDS, seconds));
lastDrewWhilePaused = false; // zoom changed the still slice — repaint // Zoom changes the still slice — redraw immediately when idle.
// If playing, the running loop will pick up the new value on the next frame.
if (!playback.isPlaying) redrawOnce();
}, },
refreshTheme(): void { refreshTheme(): void {
theme = readTheme(); theme = readTheme();
lastDrewWhilePaused = false; // re-theme is visible immediately, even when paused // Theme change is immediately visible — redraw the still slice when idle.
// If playing, the running loop will pick up the new theme on the next frame.
if (!playback.isPlaying) redrawOnce();
}, },
dispose(): void { dispose(): void {
disposed = true; disposed = true;
if (rafId !== null) { stopLoop();
cancelAnimationFrame(rafId); resizeObserver.disconnect();
rafId = null;
}
}, },
}; };
} }