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));