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
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { useEffect, useRef } from 'react';
import { Howl } from 'howler';
import useGameStore from '../stores/useGameStore';
import usePreferences from '../stores/usePreferences';
import useAudio from '../hooks/useAudio';
import moveSoundUrl from '../assets/audio/chess-move.mp3';
import captureSoundUrl from '../assets/audio/chess-capture.mp3';
import { SCRUB_THRESHOLD_MS } from '../constants';

const placeSound = new Howl({ src: [moveSoundUrl], volume: 0.5 });
const captureSound = new Howl({ src: [captureSoundUrl], volume: 0.5 });

export function SoundEffects() {
const { game, options } = useGameStore();
const soundEnabled = usePreferences((state) => state.soundEnabled);
const sounds = useAudio({ move: moveSoundUrl, capture: captureSoundUrl });
const lastPlayedRef = useRef(0);
const lastStep = useRef(0);

Expand All @@ -25,14 +23,13 @@ export function SoundEffects() {
const currentMove = history.at(-1);
const captured = currentMove?.isCapture();

// Prevent audio spam when scrubbing quickly (e.g. holding arrow keys)
const now = performance.now();
if (now - lastPlayedRef.current < SCRUB_THRESHOLD_MS) return;
lastPlayedRef.current = now;

placeSound.play();
if (captured) captureSound.play();
}, [game, options.step, soundEnabled]);
sounds.move.play();
if (captured) sounds.capture.play();
}, [game, options.step, sounds, soundEnabled]);

return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useEffect, useRef, useState } from 'react';
import { Howl, Howler } from 'howler';

type SoundConfig = { src: string; volume?: number };
type SoundMap<T extends string> = Record<T, SoundConfig | string>;
type Sounds<T extends string> = Record<T, Howl>;

function createSounds<T extends string>(map: SoundMap<T>): Sounds<T> {
const entries = Object.entries<SoundConfig | string>(map).map(([key, value]) => {
const config = typeof value === 'string' ? { src: value } : value;
return [key, new Howl({ src: [config.src], volume: config.volume ?? 0.5 })];
});
return Object.fromEntries(entries) as Sounds<T>;
}

export default function useAudio<T extends string>(map: SoundMap<T>): Sounds<T> {
const mapRef = useRef(map);
const [sounds, setSounds] = useState(() => createSounds(map));

useEffect(() => {
function cleanup() {
window.removeEventListener('pointerdown', resume);
window.removeEventListener('keydown', resume);
}

function resume() {
Howler.unload();
setSounds(createSounds(mapRef.current));
cleanup();
}

const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') return;
const state = Howler.ctx?.state as string;
if (state !== 'interrupted' && state !== 'suspended') return;

window.addEventListener('pointerdown', resume);
window.addEventListener('keydown', resume);
};

document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
cleanup();
};
}, []);

return sounds;
}