import { useCallback, useEffect, useRef, useState } from 'react'; import { detectCardShapesFromFrame, DETECTION_START_DELAY_MS, mergeDetectedShapesIntoTrackedCards, MIN_STABLE_COUNT_FOR_VERIFY, selectCardsReadyForVerification, SHAPE_DETECTION_INTERVAL_MS, VERIFICATION_INTERVAL_MS, } from './scanner-card-detection.js'; const SESSION_DEVICE_KEY = 'scanner:last-camera-device-id'; const DEVICE_PICKER_MESSAGES = { loading: 'Detecting camerasโ€ฆ', ready: '', empty: 'No camera found. Connect a webcam or use Upload Image.', denied: 'Camera access blocked. Allow camera permission or use Upload Image.', error: "Couldn't list cameras. Try again or use Upload Image.", }; /** * Camera stream + card shape detection loop for the card scanner. * Identification callbacks stay in the parent component. */ export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef, autoDetectPausedRef, facingMode = 'environment', }) { const [isStreaming, setIsStreaming] = useState(false); const [isDetecting, setIsDetecting] = useState(false); const [trackedCards, setTrackedCards] = useState([]); const [videoMetrics, setVideoMetrics] = useState({ width: 0, height: 0 }); const [activeFacingMode, setActiveFacingMode] = useState(facingMode); const [videoDevices, setVideoDevices] = useState([]); const [selectedDeviceId, setSelectedDeviceIdState] = useState(() => { try { return sessionStorage.getItem(SESSION_DEVICE_KEY) || ''; } catch { return ''; } }); const [devicePickerStatus, setDevicePickerStatus] = useState('loading'); const [devicePickerMessage, setDevicePickerMessage] = useState( DEVICE_PICKER_MESSAGES.loading ); const videoRef = useRef(null); const canvasRef = useRef(null); const detectionCanvasRef = useRef(null); const streamRef = useRef(null); const detectionIntervalRef = useRef(null); const trackingIntervalRef = useRef(null); const trackedCardsRef = useRef([]); const nextCardIdRef = useRef(1); const isStreamingRef = useRef(false); const activeFacingModeRef = useRef(activeFacingMode); const facingModeInitializedRef = useRef(false); const selectedDeviceIdRef = useRef(selectedDeviceId); const selectedDeviceIdInitializedRef = useRef(false); useEffect(() => { isStreamingRef.current = isStreaming; }, [isStreaming]); useEffect(() => { activeFacingModeRef.current = activeFacingMode; }, [activeFacingMode]); useEffect(() => { selectedDeviceIdRef.current = selectedDeviceId; }, [selectedDeviceId]); const persistSelectedDeviceId = useCallback((deviceId) => { try { if (deviceId) { sessionStorage.setItem(SESSION_DEVICE_KEY, deviceId); } else { sessionStorage.removeItem(SESSION_DEVICE_KEY); } } catch { // sessionStorage may be unavailable in private mode or SSR } }, []); const setSelectedDeviceId = useCallback((deviceId) => { setSelectedDeviceIdState(deviceId); persistSelectedDeviceId(deviceId); }, [persistSelectedDeviceId]); const refreshVideoDevices = useCallback(async () => { if (!navigator.mediaDevices?.enumerateDevices) { setDevicePickerStatus('error'); setDevicePickerMessage(DEVICE_PICKER_MESSAGES.error); return; } try { const devices = await navigator.mediaDevices.enumerateDevices(); const videoInputs = devices.filter((device) => device.kind === 'videoinput'); setVideoDevices(videoInputs); if (videoInputs.length === 0) { setDevicePickerStatus('empty'); setDevicePickerMessage(DEVICE_PICKER_MESSAGES.empty); return; } const availableIds = videoInputs.map((device) => device.deviceId); const currentId = selectedDeviceIdRef.current; if (currentId && !availableIds.includes(currentId)) { const fallbackId = videoInputs[0]?.deviceId || ''; selectedDeviceIdRef.current = fallbackId; setSelectedDeviceIdState(fallbackId); persistSelectedDeviceId(fallbackId); } setDevicePickerStatus('ready'); setDevicePickerMessage(DEVICE_PICKER_MESSAGES.ready); } catch (err) { console.error('enumerateDevices failed:', err); setDevicePickerStatus('error'); setDevicePickerMessage(DEVICE_PICKER_MESSAGES.error); } }, [persistSelectedDeviceId]); useEffect(() => { if (canvasRef.current) { canvasRef.current.getContext('2d', { willReadFrequently: true }); } if (detectionCanvasRef.current) { detectionCanvasRef.current.getContext('2d', { willReadFrequently: true }); } }, []); const detectCardShapes = () => { if (!videoRef.current || !detectionCanvasRef.current || !isStreamingRef.current) return []; return detectCardShapesFromFrame(videoRef.current, detectionCanvasRef.current); }; const updateTrackedCards = (detectedShapes) => { const { cards, nextCardId } = mergeDetectedShapesIntoTrackedCards( trackedCardsRef.current, detectedShapes, nextCardIdRef.current ); nextCardIdRef.current = nextCardId; trackedCardsRef.current = cards; setTrackedCards(cards); }; const stopDetection = () => { console.log('๐Ÿ›‘ Stopping card detection...'); setIsDetecting(false); if (detectionIntervalRef.current) { clearInterval(detectionIntervalRef.current); detectionIntervalRef.current = null; } if (trackingIntervalRef.current) { clearInterval(trackingIntervalRef.current); trackingIntervalRef.current = null; } trackedCardsRef.current = []; setTrackedCards([]); }; const removeTrackedCard = useCallback((cardId) => { const next = trackedCardsRef.current.filter((card) => card.id !== cardId); trackedCardsRef.current = next; setTrackedCards(next); }, []); const resetTrackedCard = useCallback((cardId) => { const next = trackedCardsRef.current.map((card) => ( card.id === cardId ? { ...card, status: 'detecting', scanAttempts: 0, negativeAt: undefined } : card )); trackedCardsRef.current = next; setTrackedCards(next); }, []); const resetScanTrackers = useCallback(() => { trackedCardsRef.current = []; setTrackedCards([]); nextCardIdRef.current = 1; }, []); /** * Manual scan: fresh detection pass, then verify the best candidate (or guide bounds). * @returns {{ ok: boolean, reason?: string }} */ const triggerManualScan = useCallback( (guideVideoBounds = null) => { if (!isStreamingRef.current) { return { ok: false, reason: 'not_streaming' }; } if (verificationPausedRef?.current) { return { ok: false, reason: 'paused' }; } resetScanTrackers(); const shapes = detectCardShapes(); updateTrackedCards(shapes); let target = trackedCardsRef.current .filter((card) => card.status === 'detecting') .sort((a, b) => b.stableCount - a.stableCount)[0]; if (!target && guideVideoBounds?.width > 0 && guideVideoBounds?.height > 0) { target = { id: nextCardIdRef.current++, bounds: guideVideoBounds, corners: null, status: 'detecting', firstSeen: Date.now(), lastSeen: Date.now(), stableCount: MIN_STABLE_COUNT_FOR_VERIFY, scanAttempts: 0, }; trackedCardsRef.current = [target]; setTrackedCards([target]); } if (!target) { return { ok: false, reason: 'no_card' }; } onVerifyCard?.(target); return { ok: true }; }, [onVerifyCard, resetScanTrackers, verificationPausedRef] ); const startDetection = () => { if (detectionIntervalRef.current || !isStreamingRef.current) return; setIsDetecting(true); detectionIntervalRef.current = setInterval(() => { if (document.hidden) return; const shapes = detectCardShapes(); updateTrackedCards(shapes); }, SHAPE_DETECTION_INTERVAL_MS); trackingIntervalRef.current = setInterval(() => { if (document.hidden) return; if (verificationPausedRef?.current) return; if (autoDetectPausedRef?.current) return; const cardsToVerify = selectCardsReadyForVerification(trackedCardsRef.current); cardsToVerify.slice(0, 1).forEach((card) => { onVerifyCard?.(card); }); }, VERIFICATION_INTERVAL_MS); }; const stopCamera = () => { setIsStreaming(false); isStreamingRef.current = false; stopDetection(); if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); streamRef.current = null; } if (videoRef.current) { videoRef.current.srcObject = null; } }; const buildVideoConstraints = () => { const deviceId = selectedDeviceIdRef.current; if (deviceId) { return { deviceId: { exact: deviceId }, width: { ideal: 1280 }, height: { ideal: 720 }, }; } return { facingMode: activeFacingModeRef.current, width: { ideal: 1280 }, height: { ideal: 720 }, aspectRatio: { ideal: 16 / 9 }, }; }; const startCamera = async () => { try { console.log('๐ŸŽฅ Starting camera...'); const stream = await navigator.mediaDevices.getUserMedia({ video: buildVideoConstraints(), }); console.log('๐Ÿ“น Camera stream obtained:', stream); if (!videoRef.current) { console.error('โŒ Video element not available'); onError?.('Video element not available'); return; } videoRef.current.srcObject = stream; streamRef.current = stream; videoRef.current.onloadedmetadata = () => { console.log('๐Ÿ“บ Video metadata loaded, attempting to play...'); videoRef.current?.play().then(() => { console.log('โ–ถ๏ธ Video playback started successfully'); setIsStreaming(true); isStreamingRef.current = true; refreshVideoDevices(); }).catch((err) => { console.error('โŒ Video playback failed:', err); onError?.(`Video playback failed: ${err.message}`); }); }; videoRef.current.onerror = (err) => { console.error('โŒ Video element error:', err); onError?.('Video element error occurred'); }; setTimeout(() => { if (!isStreamingRef.current && videoRef.current?.readyState >= 2) { console.log('๐Ÿ”„ Fallback: Attempting to play video directly...'); videoRef.current.play().then(() => { console.log('โ–ถ๏ธ Fallback video playback started'); setIsStreaming(true); isStreamingRef.current = true; refreshVideoDevices(); }).catch(console.error); } }, 2000); } catch (err) { console.error('โŒ Camera access error:', err); if (err?.name === 'NotAllowedError') { setDevicePickerStatus('denied'); setDevicePickerMessage(DEVICE_PICKER_MESSAGES.denied); } onError?.(`Unable to access camera: ${err.message}`); } }; useEffect(() => { const video = videoRef.current; if (!video || !isStreaming) return undefined; const syncVideoMetrics = () => { setVideoMetrics({ width: video.videoWidth || 0, height: video.videoHeight || 0, }); }; video.addEventListener('loadedmetadata', syncVideoMetrics); video.addEventListener('resize', syncVideoMetrics); syncVideoMetrics(); return () => { video.removeEventListener('loadedmetadata', syncVideoMetrics); video.removeEventListener('resize', syncVideoMetrics); }; }, [isStreaming]); useEffect(() => { if (!isStreaming || isDetecting) return undefined; const timerId = setTimeout(() => { startDetection(); }, DETECTION_START_DELAY_MS); return () => clearTimeout(timerId); // eslint-disable-next-line react-hooks/exhaustive-deps -- start detection once per stream session }, [isStreaming]); useEffect(() => { if (!isStreaming) return undefined; import('./ocr-worker.js').catch(() => {}); return undefined; }, [isStreaming]); useEffect(() => { return () => { stopCamera(); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- run stopCamera only on unmount }, []); const switchFacingMode = useCallback(() => { setActiveFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment')); }, []); useEffect(() => { if (!facingModeInitializedRef.current) { facingModeInitializedRef.current = true; return; } if (!isStreamingRef.current) return; stopCamera(); startCamera(); // eslint-disable-next-line react-hooks/exhaustive-deps -- restart stream when facing mode toggles }, [activeFacingMode]); useEffect(() => { if (!selectedDeviceIdInitializedRef.current) { selectedDeviceIdInitializedRef.current = true; return; } if (!isStreamingRef.current) return; stopCamera(); startCamera(); // eslint-disable-next-line react-hooks/exhaustive-deps -- restart stream when selected device changes }, [selectedDeviceId]); return { videoRef, canvasRef, detectionCanvasRef, isStreaming, isDetecting, trackedCards, videoMetrics, startCamera, stopCamera, removeTrackedCard, resetTrackedCard, resetScanTrackers, triggerManualScan, streamRef, facingMode: activeFacingMode, switchFacingMode, videoDevices, selectedDeviceId, setSelectedDeviceId, devicePickerStatus, devicePickerMessage, }; }