diff --git a/neode-ui/src/composables/__tests__/useAudioPlayer.test.ts b/neode-ui/src/composables/__tests__/useAudioPlayer.test.ts index e13a3fdb..6a7cade7 100644 --- a/neode-ui/src/composables/__tests__/useAudioPlayer.test.ts +++ b/neode-ui/src/composables/__tests__/useAudioPlayer.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { useAudioPlayer } from '../useAudioPlayer' // Mock HTMLAudioElement +let lastMockAudio: MockAudio | undefined + class MockAudio { src = '' currentTime = 0 @@ -9,6 +11,10 @@ class MockAudio { paused = true private listeners: Record void>> = {} + constructor() { + lastMockAudio = this + } + addEventListener(event: string, handler: () => void) { if (!this.listeners[event]) this.listeners[event] = [] this.listeners[event].push(handler) @@ -18,7 +24,12 @@ class MockAudio { // no-op for tests } + shouldRejectPlay = false + play() { + if (this.shouldRejectPlay) { + return Promise.reject(new DOMException('no supported source', 'NotSupportedError')) + } this.paused = false this.emit('play') return Promise.resolve() @@ -47,6 +58,7 @@ describe('useAudioPlayer', () => { // Reset singleton state by stopping any active playback const player = useAudioPlayer() player.stop() + if (lastMockAudio) lastMockAudio.shouldRejectPlay = false }) it('returns all expected properties', () => { @@ -128,6 +140,27 @@ describe('useAudioPlayer', () => { expect(player.progress.value).toBe(0) }) + it('play() rejection is caught, not left as an unhandled promise rejection', async () => { + // Regression: play() rejects independently of the 'error' event (e.g. a + // peer-content 404 with no decodable source) — this used to be an + // unhandled rejection in the browser console even though the 'error' + // listener already set a friendly message (2026-07-01). + const player = useAudioPlayer() + // Initialize the singleton Audio element first (a no-op play call). + player.play('/audio/warmup.mp3', 'Warmup') + player.stop() + + lastMockAudio!.shouldRejectPlay = true + // Calling play() must not throw synchronously nor leave a rejected + // promise unhandled — if useAudioPlayer's play() didn't .catch() the + // rejection, `loading` would never flip back to false, since nothing + // else resets it on this path (that's the real regression signal). + expect(() => player.play('/audio/broken.mp3', 'Broken')).not.toThrow() + await Promise.resolve() + await Promise.resolve() + expect(player.loading.value).toBe(false) + }) + it('shared state across multiple useAudioPlayer calls', () => { const p1 = useAudioPlayer() const p2 = useAudioPlayer() diff --git a/neode-ui/src/composables/useAudioPlayer.ts b/neode-ui/src/composables/useAudioPlayer.ts index 05d64f68..5c8ecbb3 100644 --- a/neode-ui/src/composables/useAudioPlayer.ts +++ b/neode-ui/src/composables/useAudioPlayer.ts @@ -69,7 +69,15 @@ function play(src: string, name: string) { currentName.value = name } - audio.value!.play() + // play() rejects (e.g. NotSupportedError when the peer 404s and there's no + // decodable source) independently of the 'error' event above — uncaught, + // this surfaces as a raw console error instead of the friendly message + // already wired up there. The 'error' listener sets the same state, so + // this just needs to stop the rejection from going unhandled. + audio.value!.play().catch(() => { + playing.value = false + loading.value = false + }) } function pause() {