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 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). ─────────────
|
||||||
|
//
|
||||||
|
// 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
|
* Draw one still frame immediately, without scheduling a new rAF. Syncs the
|
||||||
* canvas stays correctly sized and a single still slice is shown when paused —
|
* canvas size first so zoom/theme/datum/resize changes are reflected correctly
|
||||||
* but we only redraw the moving content while playing. A backgrounded tab gets
|
* even when the loop is not running.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
let lastDrewWhilePaused = false;
|
function redrawOnce(): void {
|
||||||
function frame(): void {
|
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
syncCanvasSize();
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
const resized = syncCanvasSize();
|
/**
|
||||||
|
* Start the rAF loop. No-op if already running or disposed — the rafId guard
|
||||||
if (playback.isPlaying) {
|
* ensures at most one loop is live at any time.
|
||||||
// Playback position is pushed in from Blazor each tick; redraw every frame
|
*/
|
||||||
// so the scroll is smooth between ticks (position is interpolated upstream).
|
function startLoop(): void {
|
||||||
draw();
|
if (disposed || rafId !== null) return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
rafId = requestAnimationFrame(frame);
|
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 {
|
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;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user