fix(visualizer): gate rAF loop on is-playing; one-shot redraws while idle (§E)
This commit is contained in:
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user