fix(playback): remove the stutter and gap between tracks

The isPlaying subscriber is unselected. Every store write during Vibe's
feedback/replan handshake called play() on the element that had just ended.
That replayed its final buffered milliseconds until the next source loaded.
Gate that subscriber and the seek subscriber on an actual value change, and
never resume a finished element.

Then close the gap the handshake leaves behind. The engine now drives two
<audio> elements. The next track buffers into the idle one 20s early. The
handover starts before `ended`, so the round-trip happens under the outgoing
tail. With a crossfade, that tail fades out under the new track. With crossfade
off, the new track waits in silence and starts the moment the tail ends.

Both are configurable under Settings -> Transitions and persist to
localStorage. Preload is on and crossfade is 400ms by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kami
2026-08-05 21:18:28 +04:00
parent ce16bb94f8
commit 8f33744f8c
5 changed files with 562 additions and 62 deletions
+55
View File
@@ -0,0 +1,55 @@
/**
* Local playback preferences (this browser only), read synchronously at store
* creation so the audio engine never runs a frame with the wrong values.
*/
export const PLAYBACK_PREF_KEYS = {
prefetchNext: 'muzick.settings.prefetchNext',
crossfadeMs: 'muzick.settings.crossfadeMs',
} as const;
/** Longest fade the UI offers. Also the longest early-advance lead. */
export const MAX_CROSSFADE_MS = 3000;
/** How far before the end of a track the next one starts buffering. */
export const PREFETCH_LEAD_SECONDS = 20;
export const PLAYBACK_PREF_DEFAULTS = {
prefetchNext: true,
/** Short by default: enough to hide the Vibe replan round-trip, short enough
* that it reads as a join rather than a mix. */
crossfadeMs: 400,
} as const;
export function readStoredPrefetchNext(): boolean {
try {
const stored = localStorage.getItem(PLAYBACK_PREF_KEYS.prefetchNext);
if (stored === 'true') return true;
if (stored === 'false') return false;
} catch { /* ignore */ }
return PLAYBACK_PREF_DEFAULTS.prefetchNext;
}
export function readStoredCrossfadeMs(): number {
try {
const stored = localStorage.getItem(PLAYBACK_PREF_KEYS.crossfadeMs);
if (stored !== null) {
const parsed = Number(stored);
if (Number.isFinite(parsed)) return clampCrossfadeMs(parsed);
}
} catch { /* ignore */ }
return PLAYBACK_PREF_DEFAULTS.crossfadeMs;
}
export function clampCrossfadeMs(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(MAX_CROSSFADE_MS, Math.max(0, Math.round(value)));
}
export function storePrefetchNext(value: boolean): void {
try { localStorage.setItem(PLAYBACK_PREF_KEYS.prefetchNext, String(value)); } catch { /* ignore */ }
}
export function storeCrossfadeMs(value: number): void {
try { localStorage.setItem(PLAYBACK_PREF_KEYS.crossfadeMs, String(value)); } catch { /* ignore */ }
}