102 lines
2.8 KiB
JavaScript
102 lines
2.8 KiB
JavaScript
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
|
|
|
||
|
|
const STORAGE_KEY = 'deckhearth:scanner-sound';
|
||
|
|
|
||
|
|
function isMobileViewport() {
|
||
|
|
if (typeof window === 'undefined') return false;
|
||
|
|
return window.matchMedia('(max-width: 768px)').matches;
|
||
|
|
}
|
||
|
|
|
||
|
|
function readPersistedPreference() {
|
||
|
|
if (typeof window === 'undefined') return null;
|
||
|
|
try {
|
||
|
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||
|
|
if (stored === 'true') return true;
|
||
|
|
if (stored === 'false') return false;
|
||
|
|
} catch {
|
||
|
|
// localStorage unavailable (e.g. private browsing on some browsers)
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Scan sound feedback via the Web Audio API.
|
||
|
|
*
|
||
|
|
* - Persists the enabled/disabled preference to localStorage.
|
||
|
|
* - Defaults to ON on mobile, OFF on desktop.
|
||
|
|
* - AudioContext created lazily on first playback (browser autoplay policy).
|
||
|
|
*/
|
||
|
|
export function useScannerSound() {
|
||
|
|
const [enabled, setEnabledState] = useState(() => {
|
||
|
|
const persisted = readPersistedPreference();
|
||
|
|
return persisted ?? isMobileViewport();
|
||
|
|
});
|
||
|
|
|
||
|
|
const audioCtxRef = useRef(null);
|
||
|
|
|
||
|
|
const setEnabled = useCallback((value) => {
|
||
|
|
setEnabledState((prev) => {
|
||
|
|
const next = typeof value === 'function' ? value(prev) : value;
|
||
|
|
try {
|
||
|
|
localStorage.setItem(STORAGE_KEY, String(next));
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
return next;
|
||
|
|
});
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const getAudioContext = useCallback(() => {
|
||
|
|
if (!audioCtxRef.current) {
|
||
|
|
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||
|
|
if (!AudioCtx) return null;
|
||
|
|
audioCtxRef.current = new AudioCtx();
|
||
|
|
}
|
||
|
|
if (audioCtxRef.current.state === 'suspended') {
|
||
|
|
audioCtxRef.current.resume().catch(() => {});
|
||
|
|
}
|
||
|
|
return audioCtxRef.current;
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const playTone = useCallback(
|
||
|
|
(frequency, durationMs) => {
|
||
|
|
if (!enabled) return;
|
||
|
|
const ctx = getAudioContext();
|
||
|
|
if (!ctx) return;
|
||
|
|
|
||
|
|
const oscillator = ctx.createOscillator();
|
||
|
|
const gain = ctx.createGain();
|
||
|
|
|
||
|
|
oscillator.type = 'sine';
|
||
|
|
oscillator.frequency.setValueAtTime(frequency, ctx.currentTime);
|
||
|
|
|
||
|
|
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
||
|
|
gain.gain.exponentialRampToValueAtTime(
|
||
|
|
0.001,
|
||
|
|
ctx.currentTime + durationMs / 1000
|
||
|
|
);
|
||
|
|
|
||
|
|
oscillator.connect(gain);
|
||
|
|
gain.connect(ctx.destination);
|
||
|
|
|
||
|
|
oscillator.start(ctx.currentTime);
|
||
|
|
oscillator.stop(ctx.currentTime + durationMs / 1000);
|
||
|
|
},
|
||
|
|
[enabled, getAudioContext]
|
||
|
|
);
|
||
|
|
|
||
|
|
const playSuccess = useCallback(() => playTone(880, 50), [playTone]);
|
||
|
|
const playError = useCallback(() => playTone(440, 80), [playTone]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
return () => {
|
||
|
|
if (audioCtxRef.current) {
|
||
|
|
audioCtxRef.current.close().catch(() => {});
|
||
|
|
audioCtxRef.current = null;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
return { enabled, setEnabled, playSuccess, playError };
|
||
|
|
}
|