deckhearth/lib/use-camera-scanner.js

254 lines
7.3 KiB
JavaScript
Raw Normal View History

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 + card 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,
};
}