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
co-authored by Claude Opus 4.7
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');
});
});