deckhearth/lib/use-scanner-flash.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

75 lines
2.1 KiB
JavaScript

import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Camera torch / flash control.
*
* @param {React.MutableRefObject<MediaStream|null>} streamRef
* A ref holding the active MediaStream from the camera hook.
*/
export function useScannerFlash(streamRef) {
const [flashOn, setFlashOn] = useState(false);
const [flashSupported, setFlashSupported] = useState(false);
const streamIdentityRef = useRef(null);
const getVideoTrack = useCallback(() => {
const stream = streamRef?.current;
if (!stream) return null;
const tracks = stream.getVideoTracks();
return tracks.length > 0 ? tracks[0] : null;
}, [streamRef]);
const probeFlashSupport = useCallback(() => {
const track = getVideoTrack();
if (!track) {
setFlashSupported(false);
return;
}
const capabilities = track.getCapabilities?.();
setFlashSupported(Boolean(capabilities?.torch));
const handleEnded = () => {
setFlashOn(false);
setFlashSupported(false);
};
track.addEventListener('ended', handleEnded);
return () => {
track.removeEventListener('ended', handleEnded);
};
}, [getVideoTrack]);
useEffect(() => {
const check = () => {
const currentStream = streamRef?.current ?? null;
if (currentStream !== streamIdentityRef.current) {
streamIdentityRef.current = currentStream;
probeFlashSupport();
}
};
const initialTimer = setTimeout(check, 0);
const interval = setInterval(check, 500);
return () => {
clearTimeout(initialTimer);
clearInterval(interval);
};
}, [probeFlashSupport, streamRef]);
const toggleFlash = useCallback(async () => {
if (!flashSupported) return;
const track = getVideoTrack();
if (!track) return;
const next = !flashOn;
try {
await track.applyConstraints({ advanced: [{ torch: next }] });
setFlashOn(next);
} catch (err) {
console.warn('Torch toggle failed:', err);
}
}, [flashSupported, flashOn, getVideoTrack]);
return { flashSupported, flashOn, toggleFlash };
}