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 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
* 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;
}
// ── rAF loop lifecycle (spec §E: cool when paused/backgrounded). ─────────────
//
// DESIGN: The loop runs ONLY while playing. When paused or stopped, no frames
// are scheduled. The still slice stays correct via one-shot redraws triggered by
// the handle methods (setZoom, refreshTheme, setDatum) and by ResizeObserver.
//
// Start/stop contract:
// startLoop() — schedules the first frame if not already running. Safe to call
// redundantly; the rafId guard prevents double-loops.
// stopLoop() — cancels any pending frame. The current frame callback will see
// 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.
/**
* 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 —
* but we only redraw the moving content while playing. A backgrounded tab gets
* rAF throttled by the browser automatically (spec §E "cool idle"); on top of
* that we skip the expensive redraw when not playing, so a paused/foregrounded
* mix also stays cheap.
* 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.
*/
let lastDrewWhilePaused = false;
function frame(): void {
function redrawOnce(): void {
if (disposed) return;
syncCanvasSize();
draw();
}
const resized = syncCanvasSize();
if (playback.isPlaying) {
// Playback position is pushed in from Blazor each tick; redraw every frame
// so the scroll is smooth between ticks (position is interpolated upstream).
draw();
lastDrewWhilePaused = false;
} else if (resized || !lastDrewWhilePaused) {
// Paused/stopped: draw the still slice once (and again only if the canvas
// resized). Holding the scroll on pause falls out of position being held.
draw();
lastDrewWhilePaused = true;
}
/**
* 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);
}
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) {
// Still playing — schedule the next frame.
rafId = requestAnimationFrame(frame);
} else {
// Playback stopped between the time this frame was queued and now.
// We already drew the final still frame above; exit the loop.
rafId = null;
}
}
// Kick off one still frame on creation so the canvas is not blank while idle
// before the first play command arrives.
redrawOnce();
return {
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: 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 {
const wasPlaying = playback.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 {
// 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));
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 {
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 {
disposed = true;
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
stopLoop();
resizeObserver.disconnect();
},
};
}