From 4a913a2d7e9d942185ca2263a025a5b3bb97c2e1 Mon Sep 17 00:00:00 2001 From: Michael O'Rourke Date: Mon, 13 Apr 2026 15:27:33 -0600 Subject: [PATCH] fix(audio): resume ctx in toggleSound and unconditionally on first gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous autoplay unlock bailed early when Howler.ctx was null — but Howler.ctx is lazy-initialized on the first Howl construction, so on first mount the effect ran before the first playback effect and never registered listeners. Toggle off/on also silently failed because resume() was only called from the deferred effect, which some browsers treat as outside the gesture window. - Register unlock listeners unconditionally when soundEnabled - Call Howler.ctx.resume() synchronously inside toggleSound (the click handler IS a user gesture) - In the unlock handler, also re-play the active Howl if it isn't currently playing — Howler's own flush doesn't always restart queued Howls after ctx resume Tests: 23 passing, including new cases for toggle resume and replay fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../client/src/contexts/SoundContext.test.tsx | 28 ++++++++++++- packages/client/src/contexts/SoundContext.tsx | 40 +++++++++++++------ 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/packages/client/src/contexts/SoundContext.test.tsx b/packages/client/src/contexts/SoundContext.test.tsx index d036151a..f8f0467c 100644 --- a/packages/client/src/contexts/SoundContext.test.tsx +++ b/packages/client/src/contexts/SoundContext.test.tsx @@ -9,7 +9,9 @@ const mockStop = vi.fn(); const mockFade = vi.fn(); const mockUnload = vi.fn(); const mockVolume = vi.fn(); +const mockPlaying = vi.fn().mockReturnValue(false); const mockResume = vi.fn().mockResolvedValue(undefined); +let mockCtxState: 'running' | 'suspended' = 'suspended'; vi.mock('howler', () => { const mockHowl = vi.fn().mockImplementation(() => ({ @@ -18,10 +20,11 @@ vi.mock('howler', () => { fade: mockFade, unload: mockUnload, volume: mockVolume, + playing: mockPlaying, })); const mockHowler = { get ctx() { - return { state: 'suspended', resume: mockResume } as unknown as AudioContext; + return { state: mockCtxState, resume: mockResume } as unknown as AudioContext; }, }; return { Howl: mockHowl, Howler: mockHowler }; @@ -82,6 +85,8 @@ describe('SoundContext', () => { mockUseAuth.mockReturnValue({ isAuthenticated: false }); mockUseMap.mockReturnValue({ currentZone: 1 }); mockUseBattle.mockReturnValue({ currentBattle: null }); + mockPlaying.mockReturnValue(false); + mockCtxState = 'suspended'; vi.clearAllMocks(); vi.useRealTimers(); }); @@ -211,6 +216,27 @@ describe('SoundContext', () => { window.dispatchEvent(new Event('pointerdown')); expect(mockResume).not.toHaveBeenCalled(); }); + + it('resumes the audio context synchronously inside toggleSound', () => { + renderWithProvider(); + expect(mockResume).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText('toggle')); + expect(mockResume).toHaveBeenCalled(); + }); + + it('replays the active Howl on unlock gesture if it was queued silently', async () => { + localStorage.setItem('ud:sound-enabled', 'true'); + renderWithProvider(); + // Initial play was called during mount + const initialPlayCalls = mockPlay.mock.calls.length; + // Simulate the Howl being queued but not actually playing + mockPlaying.mockReturnValue(false); + + window.dispatchEvent(new Event('pointerdown')); + + // Should have called play again to flush the queue + expect(mockPlay.mock.calls.length).toBeGreaterThan(initialPlayCalls); + }); }); describe('battle music', () => { diff --git a/packages/client/src/contexts/SoundContext.tsx b/packages/client/src/contexts/SoundContext.tsx index 18247e9e..2f9f7dcd 100644 --- a/packages/client/src/contexts/SoundContext.tsx +++ b/packages/client/src/contexts/SoundContext.tsx @@ -120,23 +120,32 @@ export const SoundProvider = ({ children }: { children: React.ReactNode }): JSX. }, [isAuthenticated, soundEnabled]); // Autoplay unlock: browsers suspend the audio context until a user gesture. - // Auto-enabling sound on auth doesn't count as a gesture — without this, the - // first ambient track queues silently and never plays until the player clicks - // something. Resume on the next pointer/key/touch event, once. + // Register listeners unconditionally when sound is enabled — Howler.ctx may + // not exist yet when this effect first runs (it's lazy-initialized on the + // first Howl construction), so we can't early-bail on ctx state. Howler has + // its own auto-unlock but in practice it doesn't always flush queued plays + // reliably, so we also nudge the active Howl back on. useEffect(() => { if (!soundEnabled) return; - const ctx = Howler.ctx; - if (!ctx || ctx.state === 'running') return; const unlock = () => { - Howler.ctx?.resume().catch(() => {}); - window.removeEventListener('pointerdown', unlock); - window.removeEventListener('keydown', unlock); - window.removeEventListener('touchstart', unlock); + const ctx = Howler.ctx; + if (ctx && ctx.state !== 'running') { + ctx.resume().catch(() => {}); + } + const active = activeTrackRef.current; + if (active) { + const cache = active.battle ? battleHowlsRef.current : ambientHowlsRef.current; + const howl = cache[active.zone]; + if (howl && !howl.playing()) { + howl.play(); + } + } }; - window.addEventListener('pointerdown', unlock, { once: false }); - window.addEventListener('keydown', unlock, { once: false }); - window.addEventListener('touchstart', unlock, { once: false }); + + window.addEventListener('pointerdown', unlock); + window.addEventListener('keydown', unlock); + window.addEventListener('touchstart', unlock); return () => { window.removeEventListener('pointerdown', unlock); window.removeEventListener('keydown', unlock); @@ -228,6 +237,13 @@ export const SoundProvider = ({ children }: { children: React.ReactNode }): JSX. }, []); const toggleSound = useCallback(() => { + // Resume the audio context synchronously here — the click handler is a + // user gesture, so the browser will honor resume(). Doing it inside the + // subsequent playback useEffect happens one microtask later, which some + // browsers treat as outside the gesture window and refuse to unlock. + if (Howler.ctx && Howler.ctx.state !== 'running') { + Howler.ctx.resume().catch(() => {}); + } setSoundEnabled((prev) => { const next = !prev; localStorage.setItem(SOUND_ENABLED_KEY, String(next));