fix(visualizer): bars recover after far-forward seeks into undecoded audio
ensureDecodeAround let a running decode pass close ANY forward gap in place — at 4x pacing, skipping 30 min ahead meant ~7.5 min for the frontier to arrive: bars held their last frame indefinitely. Now a gap beyond 15s restarts the pass at the seek target (network coverage there in ~1.6s, verified); smaller gaps close in place (cheaper than a reconnect + range request). Seek-key holds are debounced (400ms) so rapid re-seeks don't reconnect-spam the stream's server, and pending seek-decode cancels on pause/stop so no ffmpeg restarts while paused. Also fixes a pre-existing config-write race that intermittently failed visualizer-toggle.test.ts: updateConfig re-resolved the config path and re-read the patch state when the deferred write-chain drained, so a queued save could land in a directory XDG_CONFIG_HOME had since been pointed at (or carry state mutated after queueing). Path and patch snapshot are now captured eagerly at call time.
This commit is contained in:
@@ -55,6 +55,9 @@ const FRAME_INTERVAL = 33;
|
||||
/** Number of PCM samples to read per frame (512 is a good FFT window) */
|
||||
const SAMPLES_PER_FRAME = 512;
|
||||
|
||||
/** Timer handle as returned by setTimeout/setInterval in this runtime. */
|
||||
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface VisualizerStore {
|
||||
@@ -96,9 +99,9 @@ function createVisualizerStore(): VisualizerStore {
|
||||
// pause/resume (segments survive; only the ffmpeg pass is killed) and
|
||||
// dropped only on episode change, stop, disable, or unload.
|
||||
let pcm: EpisodePcmCache | null = null;
|
||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let frameTimer: TimerHandle | null = null;
|
||||
let sampleBuffer: Float64Array | null = null;
|
||||
let unloadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let unloadTimer: TimerHandle | null = null;
|
||||
|
||||
// What the running pipeline was started with — lets the playback effect
|
||||
// tell "nothing changed, stay warm" from "must restart".
|
||||
@@ -209,6 +212,8 @@ function createVisualizerStore(): VisualizerStore {
|
||||
clearInterval(frameTimer);
|
||||
frameTimer = null;
|
||||
}
|
||||
clearTimeout(seekDecodeTimer);
|
||||
seekDecodeTimer = undefined;
|
||||
if (pcm) {
|
||||
pcm.stop();
|
||||
// Keep the (now cache-less, url-tagged) object: a re-start of the
|
||||
@@ -233,6 +238,10 @@ function createVisualizerStore(): VisualizerStore {
|
||||
clearInterval(frameTimer);
|
||||
frameTimer = null;
|
||||
}
|
||||
// Cancel any debounced seek-decode: it would restart ffmpeg while
|
||||
// paused, defeating the "no background CPU while paused" contract.
|
||||
clearTimeout(seekDecodeTimer);
|
||||
seekDecodeTimer = undefined;
|
||||
if (pcm) pcm.pauseDecode();
|
||||
// Cava plan + sampleBuffer stay alive — cheap to reuse on resume.
|
||||
// Clear the loading spinner: if the pipeline never produced bars
|
||||
@@ -375,6 +384,7 @@ function createVisualizerStore(): VisualizerStore {
|
||||
// while the last frame holds.
|
||||
|
||||
let lastSyncPosition = 0;
|
||||
let seekDecodeTimer: TimerHandle | undefined;
|
||||
createEffect(
|
||||
on(audioPlaybackSignals.position, (pos) => {
|
||||
if (!audioPlaybackSignals.isPlaying() || !pcm) {
|
||||
@@ -386,7 +396,16 @@ function createVisualizerStore(): VisualizerStore {
|
||||
lastSyncPosition = pos;
|
||||
|
||||
if (delta > 2) {
|
||||
pcm.ensureDecodeAround(pos);
|
||||
// Debounce: holding the seek key fires a jump per poll tick —
|
||||
// without debounce each one restarts ffmpeg, spamming network
|
||||
// reconnects against the stream's server. Wait for the user to
|
||||
// settle, then decode at the final position.
|
||||
clearTimeout(seekDecodeTimer);
|
||||
const target = pcm; // capture for the timer
|
||||
seekDecodeTimer = setTimeout(() => {
|
||||
seekDecodeTimer = undefined;
|
||||
target.ensureDecodeAround(untrack(audioPlaybackSignals.position));
|
||||
}, 400);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user