feat(web): stream resilience — preload=auto, stall recovery, next-track prefetch

Phases 1+2 of the background-play resilience work. Net effect on
desktop: gap-free track transitions on slow networks, automatic
recovery from transient stalls / network errors mid-track.

- preload="metadata" → "auto" on the main audio element so the
  browser pre-buffers more aggressively.
- attachStallRetry() — listens for stalled / waiting / network-error
  events. Schedules a reload-from-current-position after delayMs
  (3s default) if the buffer hasn't recovered. MEDIA_ERR_NETWORK
  triggers immediate retry. Bounded by maxRetries (3 default) to
  prevent tight loops on persistent failures.
- audioLoader seam — `lib/player/audioLoader.ts` exposes a
  resolve(track) → URL + optional prefetch(track) hint. Default
  returns track.stream_url; the planned Tauri shell can swap via
  setAudioLoader() to return blob URLs backed by a Rust cache,
  mirroring the Flutter drift+LockCachingAudioSource pattern.
- Hidden prefetch <audio> element. When current track passes 50%,
  start loading queue[index+1]. On track-end the swap is gap-free
  via browser HTTP cache + audio buffer warm-up.

Phase 3 (service worker chunk cache) deliberately deferred — that
work belongs in the Tauri shell as native Rust caching, not as a
web-only investment that would compete with it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 17:43:41 -04:00
parent 1daf0b7fdf
commit 7bc254e17e
5 changed files with 349 additions and 2 deletions
+52
View File
@@ -0,0 +1,52 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { TrackRef } from '$lib/api/types';
import { audioLoader, setAudioLoader, __resetAudioLoader } from './audioLoader';
const sampleTrack: TrackRef = {
id: 't1',
title: 'Sample',
album_id: 'a1',
album_title: 'Album',
artist_id: 'ar1',
artist_name: 'Artist',
duration_sec: 180,
stream_url: '/api/tracks/t1/stream'
};
afterEach(() => {
__resetAudioLoader();
});
describe('audioLoader', () => {
test('default resolve returns the track stream_url', () => {
expect(audioLoader.resolve(sampleTrack)).toBe('/api/tracks/t1/stream');
});
test('default prefetch is a no-op (does not throw)', () => {
expect(() => audioLoader.prefetch?.(sampleTrack)).not.toThrow();
});
test('setAudioLoader swaps the resolver', () => {
setAudioLoader({
resolve: (t) => `blob:cached-${t.id}`
});
expect(audioLoader.resolve(sampleTrack)).toBe('blob:cached-t1');
});
test('setAudioLoader swaps the prefetch hook', () => {
const spy = vi.fn();
setAudioLoader({
resolve: (t) => t.stream_url,
prefetch: spy
});
audioLoader.prefetch?.(sampleTrack);
expect(spy).toHaveBeenCalledWith(sampleTrack);
});
test('__resetAudioLoader restores the default', () => {
setAudioLoader({ resolve: () => 'blob:swapped' });
expect(audioLoader.resolve(sampleTrack)).toBe('blob:swapped');
__resetAudioLoader();
expect(audioLoader.resolve(sampleTrack)).toBe('/api/tracks/t1/stream');
});
});
+41
View File
@@ -0,0 +1,41 @@
// Audio loader seam. Today: returns the server-relative stream URL
// directly. In the future Tauri shell, the loader can be swapped to
// return blob URLs backed by a Rust-side cache (mirroring the Flutter
// drift+LockCachingAudioSource pattern), without touching the player UI.
//
// Two operations:
// - resolve(track) — returns the URL the <audio> element should src.
// - prefetch(track) — hint that a track will be needed soon. Default
// no-op (the browser handles it via the prefetch <audio> element);
// Tauri can warm its native cache here.
import type { TrackRef } from '$lib/api/types';
export type AudioLoader = {
resolve: (track: TrackRef) => string;
prefetch?: (track: TrackRef) => void;
};
const defaultLoader: AudioLoader = {
resolve: (track) => track.stream_url
};
let _current: AudioLoader = defaultLoader;
export const audioLoader: AudioLoader = {
resolve: (track) => _current.resolve(track),
prefetch: (track) => _current.prefetch?.(track)
};
/**
* Replace the active loader. Call once at startup from the Tauri shell;
* web shell uses the default and never calls this.
*/
export function setAudioLoader(loader: AudioLoader): void {
_current = loader;
}
/** Test-only: restore the default loader between tests. */
export function __resetAudioLoader(): void {
_current = defaultLoader;
}
+133
View File
@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { attachStallRetry } from './stallRetry';
// Minimal HTMLAudioElement stand-in for vitest. Real DOM elements
// are over-featured; we only need the methods + addEventListener.
function makeAudioStub() {
const handlers = new Map<string, Set<EventListener>>();
const stub = {
currentTime: 0,
paused: false,
readyState: 4,
error: null as MediaError | null,
load: vi.fn(),
play: vi.fn().mockResolvedValue(undefined),
addEventListener(type: string, fn: EventListener) {
if (!handlers.has(type)) handlers.set(type, new Set());
handlers.get(type)!.add(fn);
},
removeEventListener(type: string, fn: EventListener) {
handlers.get(type)?.delete(fn);
},
fire(type: string) {
handlers.get(type)?.forEach((fn) => fn(new Event(type)));
}
};
return stub;
}
describe('attachStallRetry', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test('stalled event triggers reload after delayMs when buffer not recovered', () => {
const el = makeAudioStub();
el.currentTime = 42;
el.readyState = 1; // not yet HAVE_FUTURE_DATA
attachStallRetry(el as unknown as HTMLAudioElement, { delayMs: 1000 });
el.fire('stalled');
expect(el.load).not.toHaveBeenCalled(); // not yet — waiting
vi.advanceTimersByTime(1000);
expect(el.load).toHaveBeenCalledTimes(1);
expect(el.currentTime).toBe(42); // restored after load()
expect(el.play).toHaveBeenCalled(); // wasPlaying = true (paused: false)
});
test('skips reload when readyState recovered before delay elapses', () => {
const el = makeAudioStub();
el.readyState = 1;
attachStallRetry(el as unknown as HTMLAudioElement, { delayMs: 1000 });
el.fire('stalled');
el.readyState = 4; // recovered
vi.advanceTimersByTime(1000);
expect(el.load).not.toHaveBeenCalled();
});
test('canplay clears retry counter so a later stall can retry again', () => {
const el = makeAudioStub();
el.readyState = 1;
attachStallRetry(el as unknown as HTMLAudioElement, {
delayMs: 100,
maxRetries: 1
});
el.fire('stalled');
vi.advanceTimersByTime(100);
expect(el.load).toHaveBeenCalledTimes(1);
el.fire('canplay'); // resets retry count
el.fire('stalled');
vi.advanceTimersByTime(100);
expect(el.load).toHaveBeenCalledTimes(2);
});
test('maxRetries caps recovery attempts', () => {
const el = makeAudioStub();
el.readyState = 1;
attachStallRetry(el as unknown as HTMLAudioElement, {
delayMs: 100,
maxRetries: 2
});
for (let i = 0; i < 5; i++) {
el.fire('stalled');
vi.advanceTimersByTime(100);
}
expect(el.load).toHaveBeenCalledTimes(2);
});
test('MEDIA_ERR_NETWORK error triggers immediate reload', () => {
const el = makeAudioStub();
el.error = { code: 2 } as MediaError;
el.currentTime = 17;
attachStallRetry(el as unknown as HTMLAudioElement);
el.fire('error');
expect(el.load).toHaveBeenCalled();
expect(el.currentTime).toBe(17);
});
test('non-network errors (DECODE, SRC_NOT_SUPPORTED) are not retried', () => {
const el = makeAudioStub();
el.error = { code: 3 } as MediaError; // MEDIA_ERR_DECODE
attachStallRetry(el as unknown as HTMLAudioElement);
el.fire('error');
expect(el.load).not.toHaveBeenCalled();
});
test('teardown removes listeners and clears pending timer', () => {
const el = makeAudioStub();
el.readyState = 1;
const teardown = attachStallRetry(el as unknown as HTMLAudioElement, {
delayMs: 100
});
el.fire('stalled');
teardown();
vi.advanceTimersByTime(100);
expect(el.load).not.toHaveBeenCalled();
// After teardown, firing events does nothing.
el.fire('stalled');
vi.advanceTimersByTime(100);
expect(el.load).not.toHaveBeenCalled();
});
});
+79
View File
@@ -0,0 +1,79 @@
// Auto-recovery for transient network failures during playback.
// Listens for stalled / waiting / network-error events and forces a
// reload-from-current-position when the audio element doesn't recover
// on its own within `delayMs`. Bounded retry count prevents tight
// loops on persistent failures (the existing onerror handler still
// reports to the store after retries exhaust).
//
// Returns a teardown function — call from $effect cleanup.
export type StallRetryOptions = {
maxRetries?: number;
delayMs?: number;
};
export function attachStallRetry(
el: HTMLAudioElement,
opts: StallRetryOptions = {}
): () => void {
const maxRetries = opts.maxRetries ?? 3;
const delayMs = opts.delayMs ?? 3000;
let timer: ReturnType<typeof setTimeout> | null = null;
let retries = 0;
function clear() {
if (timer) {
clearTimeout(timer);
timer = null;
}
}
function attemptRecovery() {
if (retries >= maxRetries) return;
retries++;
const lastPos = el.currentTime;
const wasPlaying = !el.paused;
el.load();
el.currentTime = lastPos;
if (wasPlaying) el.play().catch(() => { /* swallowed; store sees the event */ });
}
function onStallish() {
if (timer) return;
timer = setTimeout(() => {
timer = null;
// readyState >= 3 (HAVE_FUTURE_DATA) means the buffer recovered.
if (el.readyState >= 3) return;
attemptRecovery();
}, delayMs);
}
function onError() {
// MediaError.MEDIA_ERR_NETWORK === 2 → transient; worth retrying.
// Other codes (DECODE, SRC_NOT_SUPPORTED) are deterministic; skip.
if (!el.error) return;
if (el.error.code !== 2) return;
attemptRecovery();
}
function onRecovered() {
retries = 0;
clear();
}
el.addEventListener('stalled', onStallish);
el.addEventListener('waiting', onStallish);
el.addEventListener('error', onError);
el.addEventListener('canplay', onRecovered);
el.addEventListener('playing', onRecovered);
return () => {
el.removeEventListener('stalled', onStallish);
el.removeEventListener('waiting', onStallish);
el.removeEventListener('error', onError);
el.removeEventListener('canplay', onRecovered);
el.removeEventListener('playing', onRecovered);
clear();
};
}
+44 -2
View File
@@ -21,9 +21,12 @@
import { useMediaSession } from '$lib/player/mediaSession.svelte';
import { useEventsDispatcher } from '$lib/player/events.svelte';
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
import { audioLoader } from '$lib/player/audioLoader';
import { attachStallRetry } from '$lib/player/stallRetry';
let { children } = $props<{ children: import('svelte').Snippet }>();
let audioEl: HTMLAudioElement | undefined = $state();
let prefetchEl: HTMLAudioElement | undefined = $state();
// Reactive guard: runs every time page.url or user.value changes.
$effect(() => {
@@ -42,9 +45,16 @@
return () => registerAudioEl(null);
});
// Auto-recovery for transient network failures during playback.
$effect(() => {
if (!audioEl) return;
const src = player.current?.stream_url;
return attachStallRetry(audioEl);
});
$effect(() => {
if (!audioEl) return;
const cur = player.current;
const src = cur ? audioLoader.resolve(cur) : null;
if (src) {
const absolute = new URL(src, window.location.origin).href;
if (audioEl.src !== absolute) audioEl.src = src;
@@ -54,6 +64,27 @@
}
});
// Phase 2 prefetch: when the current track passes the halfway mark,
// start loading the next track's bytes into a hidden <audio> element.
// The browser HTTP cache + audio buffer warm-up means the swap on
// track-end is gap-free on slow networks. Reset src when no next
// track exists or the index advances.
$effect(() => {
if (!prefetchEl) return;
const next = player.queue[player.index + 1];
const ratio = player.duration > 0 ? player.position / player.duration : 0;
if (next && ratio >= 0.5) {
const nextSrc = audioLoader.resolve(next);
if (prefetchEl.src !== new URL(nextSrc, window.location.origin).href) {
prefetchEl.src = nextSrc;
audioLoader.prefetch?.(next);
}
} else if (!next && prefetchEl.src) {
prefetchEl.removeAttribute('src');
prefetchEl.load();
}
});
$effect(() => {
if (audioEl) audioEl.volume = player.volume;
});
@@ -87,7 +118,7 @@
<audio
bind:this={audioEl}
preload="metadata"
preload="auto"
ontimeupdate={() => audioEl && reportTimeUpdate(audioEl.currentTime)}
onloadedmetadata={() => {
if (!audioEl) return;
@@ -104,6 +135,17 @@
onerror={() => reportStateFromAudio('error', audioEl?.error?.message)}
></audio>
<!-- Hidden prefetch element. Loads the next track's bytes ahead of
time so the swap on track-end is gap-free. Muted + display:none
so it can't be played or interacted with directly. -->
<audio
bind:this={prefetchEl}
preload="auto"
muted
aria-hidden="true"
style="display: none"
></audio>
<QueueDrawer />
<QueryClientProvider client={queryClient}>