fix(audio): survive navigation, clear stale state, silence noisy logs

Bug 1 — playback stops on navigation:
WaveSurfer v7 creates its <audio> element inside the container div. When
SongPage unmounts, the container is removed from the DOM, taking the audio
element with it and stopping playback. Fix: AudioService owns a persistent
hidden <audio> element on document.body and passes it to WaveSurfer via the
`media` option. WaveSurfer uses it for playback but does not destroy it on
WaveSurfer.destroy(), so audio survives any number of navigations.

Bug 2 — stale playhead/duration when opening a new song:
initialize() called destroyWaveSurfer() but never reset the store, so the
previous song's currentTime, duration, and isPlaying leaked into the new song's
load sequence. Fix: reset those three fields in the store immediately after
tearing down the old WaveSurfer instance. cleanup() also now resets duration.

Bug 3 — excessive console noise on mobile:
Remove console.warn from play() (silent return when not ready) and from
useWaveform's play() wrapper. Only console.error on actual errors remains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mistral Vibe
2026-04-08 21:10:21 +02:00
parent 25dca3c788
commit d08eebf0eb
3 changed files with 51 additions and 29 deletions

View File

@@ -129,6 +129,22 @@ describe('AudioService', () => {
expect(vi.mocked(WaveSurfer.create).mock.calls.length).toBe(createCallCount); // no new instance
});
it('resets store state when URL changes', async () => {
await initService(service, { url: 'http://example.com/a.mp3', duration: 180 });
usePlayerStore.getState().batchUpdate({ isPlaying: true, currentTime: 45 });
const WaveSurfer = (await import('wavesurfer.js')).default;
const mockWs2 = createMockWaveSurfer();
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs2 as any);
const p2 = service.initialize(makeContainer(), 'http://example.com/b.mp3');
mockWs2._emit('ready');
await p2;
// State should be reset, not carry over from the previous song
expect(usePlayerStore.getState().currentTime).toBe(0);
expect(usePlayerStore.getState().isPlaying).toBe(false);
});
it('destroys old instance when URL changes', async () => {
const WaveSurfer = (await import('wavesurfer.js')).default;
@@ -152,10 +168,8 @@ describe('AudioService', () => {
describe('play()', () => {
it('does nothing if not ready', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await service.play('song-1', 'band-1');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('before ready'));
warnSpy.mockRestore();
// play() silently returns when not ready — no throw, no warn
await expect(service.play('song-1', 'band-1')).resolves.toBeUndefined();
});
it('calls wavesurfer.play()', async () => {
@@ -225,23 +239,14 @@ describe('AudioService', () => {
expect(service.isWaveformReady()).toBe(false);
});
it('resets isPlaying and currentTime in the store', async () => {
await initService(service);
it('resets isPlaying, currentTime, and duration in the store', async () => {
await initService(service, { duration: 180 });
usePlayerStore.getState().batchUpdate({ isPlaying: true, currentTime: 30 });
service.cleanup();
const state = usePlayerStore.getState();
expect(state.isPlaying).toBe(false);
expect(state.currentTime).toBe(0);
});
it('pauses before destroying if playing', async () => {
const callOrder: string[] = [];
const mockWs = await initService(service);
mockWs.isPlaying.mockReturnValue(true);
mockWs.pause.mockImplementation(() => callOrder.push('pause'));
mockWs.destroy.mockImplementation(() => callOrder.push('destroy'));
service.cleanup();
expect(callOrder).toEqual(['pause', 'destroy']);
expect(state.duration).toBe(0);
});
it('is safe to call when not initialized', () => {