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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user