deckhearth/lib/use-scanner-sound.js
Randall Stillwell cf9fea0726 feat(scanner): rebuild as mobile-first three-phase flow
Replace the desktop-first, everything-at-once scanner layout with a
phased mobile-optimized experience: Setup → Scanning → Review.

Phase 1 (Setup): destination picker, game filter, deck mode toggle,
scan history (last 5 sessions).

Phase 2 (Scanning): full-screen camera with auto-start, haptic + sound
feedback on card detection, torch/flash toggle, count pill, bottom-sheet
disambiguation (replaces full-screen modal).

Phase 3 (Review): card list with inline condition/foil/qty edits,
batch confirm, 30-second undo, deck progress indicator.

New features:
- Deck mode (progress toward 40/60/99 card target)
- Scan history (persisted to localStorage)
- Sound feedback (Web Audio oscillator, configurable)
- Offline queue (localStorage persistence + auto-retry on reconnect)
- Camera flash/torch toggle
- Batch ownership API (replaces N+1 per-card fetches)
- Visibility pause (detection loop stops when tab is backgrounded)

Convoy: scanner-rebuild
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 08:42:58 -05:00

101 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 };
}