import { useCallback, useEffect, useRef, useState } from 'react'; import GlassSurface from '../ui/GlassSurface.js'; import ScannerToast from './ScannerToast.js'; import ScannerCountPill from './ScannerCountPill.js'; import ScannerScanPeek from './ScannerScanPeek.js'; import ScannerDisambiguation from './ScannerDisambiguation.js'; import { useScannerSound } from '../../lib/use-scanner-sound.js'; import { useScannerFlash } from '../../lib/use-scanner-flash.js'; const TOAST_DURATION_MS = 2500; function DetectionFrame({ card, videoMetrics }) { const label = card.status === 'scanned' ? `Card ${card.id} scanned` : `Card ${card.id} identified`; return (
); } function ChromeIconButton({ onClick, label, pressed, busy, children, className = '', }) { return ( ); } export default function ScannerCamera({ queue, camera, identification, onBack = () => {}, onOpenCheckout = () => {}, onGalleryIdentify, latestPeekCard = null, cartCount, isCheckoutOpen = false, verificationPausedRef, }) { const [toast, setToast] = useState({ message: '', visible: false, type: 'success' }); const [galleryBusy, setGalleryBusy] = useState(false); const toastTimerRef = useRef(null); const prevCountRef = useRef(queue.scannedCards?.length ?? 0); const galleryInputRef = useRef(null); const { videoRef, canvasRef, detectionCanvasRef, isStreaming, trackedCards, videoMetrics, startCamera, streamRef, facingMode, switchFacingMode, } = camera; const sound = useScannerSound(); const flash = useScannerFlash(streamRef); const resolvedCartCount = cartCount ?? queue.scannedCards?.length ?? 0; useEffect(() => { startCamera(); // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount }, []); const currentCount = queue.scannedCards?.length ?? 0; useEffect(() => { if (currentCount > prevCountRef.current) { const newest = queue.scannedCards[0]; const cardName = newest?.name || newest?.card?.name || 'Card'; navigator.vibrate?.(50); sound.playSuccess(); clearTimeout(toastTimerRef.current); setToast({ message: `${cardName} added`, visible: true, type: 'success' }); toastTimerRef.current = setTimeout(() => { setToast((t) => ({ ...t, visible: false })); }, TOAST_DURATION_MS); } prevCountRef.current = currentCount; }, [currentCount]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only reacts to count useEffect(() => { return () => clearTimeout(toastTimerRef.current); }, []); useEffect(() => { if (facingMode === 'user' && flash.flashOn) { flash.toggleFlash(); } // eslint-disable-next-line react-hooks/exhaustive-deps -- only react to facing mode changes }, [facingMode]); const handleGalleryClick = useCallback(() => { galleryInputRef.current?.click(); }, []); const handleGalleryChange = useCallback( async (event) => { const file = event.target.files?.[0]; event.target.value = ''; if (!file) return; const identify = onGalleryIdentify || identification.identifyFromGalleryFile?.bind(identification); if (!identify) return; setGalleryBusy(true); try { await identify(file); } finally { setGalleryBusy(false); } }, [identification, onGalleryIdentify] ); const foundCards = trackedCards.filter( (c) => c.status === 'confirmed' || c.status === 'scanned' ); const hasMetrics = videoMetrics.width > 0 && videoMetrics.height > 0; const showFlash = flash.flashSupported && facingMode === 'environment'; const scanStatus = isCheckoutOpen ? 'Checkout open' : isStreaming ? 'Scanning…' : 'Starting camera…'; const switchCameraLabel = facingMode === 'environment' ? 'Switch to front camera' : 'Switch to rear camera'; return (