Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion packages/client/src/contexts/SoundContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand All @@ -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 };
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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', () => {
Expand Down
40 changes: 28 additions & 12 deletions packages/client/src/contexts/SoundContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down
Loading