import { useCallback, useEffect, useRef, useState } from 'react'; import { detectCardShapesFromFrame, DETECTION_START_DELAY_MS, mergeDetectedShapesIntoTrackedCards, selectCardsReadyForVerification, SHAPE_DETECTION_INTERVAL_MS, VERIFICATION_INTERVAL_MS, } from './scanner-card-detection.js'; /** * Camera stream + OpenCV shape detection loop for the card scanner. * Identification callbacks stay in the parent component. */ export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef, 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 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); useEffect(() => { isStreamingRef.current = isStreaming; }, [isStreaming]); useEffect(() => { activeFacingModeRef.current = activeFacingMode; }, [activeFacingMode]); 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 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; 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 startCamera = async () => { try { console.log('🎥 Starting camera...'); const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: activeFacingModeRef.current, width: { ideal: 1280 }, height: { ideal: 720 }, aspectRatio: { ideal: 16 / 9 }, }, }); 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; }).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; }).catch(console.error); } }, 2000); } catch (err) { console.error('❌ Camera access error:', err); 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(() => { 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]); return { videoRef, canvasRef, detectionCanvasRef, isStreaming, isDetecting, trackedCards, videoMetrics, startCamera, stopCamera, streamRef, facingMode: activeFacingMode, switchFacingMode, }; }