2026-08-14 21:20:43 -04:00
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
2026-06-02 16:44:56 -04:00
|
|
|
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.
|
|
|
|
|
*/
|
2026-08-14 21:20:43 -04:00
|
|
|
export function useCameraScanner({
|
|
|
|
|
onError,
|
|
|
|
|
onVerifyCard,
|
|
|
|
|
verificationPausedRef,
|
|
|
|
|
facingMode = 'environment',
|
|
|
|
|
}) {
|
2026-06-02 16:44:56 -04:00
|
|
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
|
|
|
const [isDetecting, setIsDetecting] = useState(false);
|
|
|
|
|
const [trackedCards, setTrackedCards] = useState([]);
|
|
|
|
|
const [videoMetrics, setVideoMetrics] = useState({ width: 0, height: 0 });
|
2026-08-14 21:20:43 -04:00
|
|
|
const [activeFacingMode, setActiveFacingMode] = useState(facingMode);
|
2026-06-02 16:44:56 -04:00
|
|
|
|
|
|
|
|
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);
|
2026-08-14 21:20:43 -04:00
|
|
|
const activeFacingModeRef = useRef(activeFacingMode);
|
|
|
|
|
const facingModeInitializedRef = useRef(false);
|
2026-06-02 16:44:56 -04:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
isStreamingRef.current = isStreaming;
|
|
|
|
|
}, [isStreaming]);
|
|
|
|
|
|
2026-08-14 21:20:43 -04:00
|
|
|
useEffect(() => {
|
|
|
|
|
activeFacingModeRef.current = activeFacingMode;
|
|
|
|
|
}, [activeFacingMode]);
|
|
|
|
|
|
2026-06-02 16:44:56 -04:00
|
|
|
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(() => {
|
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 09:42:58 -04:00
|
|
|
if (document.hidden) return;
|
2026-06-02 16:44:56 -04:00
|
|
|
const shapes = detectCardShapes();
|
|
|
|
|
updateTrackedCards(shapes);
|
|
|
|
|
}, SHAPE_DETECTION_INTERVAL_MS);
|
|
|
|
|
|
|
|
|
|
trackingIntervalRef.current = setInterval(() => {
|
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 09:42:58 -04:00
|
|
|
if (document.hidden) return;
|
2026-06-02 16:44:56 -04:00
|
|
|
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: {
|
2026-08-14 21:20:43 -04:00
|
|
|
facingMode: activeFacingModeRef.current,
|
2026-06-02 16:44:56 -04:00
|
|
|
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
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-08-14 21:20:43 -04:00
|
|
|
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]);
|
|
|
|
|
|
2026-06-02 16:44:56 -04:00
|
|
|
return {
|
|
|
|
|
videoRef,
|
|
|
|
|
canvasRef,
|
|
|
|
|
detectionCanvasRef,
|
|
|
|
|
isStreaming,
|
|
|
|
|
isDetecting,
|
|
|
|
|
trackedCards,
|
|
|
|
|
videoMetrics,
|
|
|
|
|
startCamera,
|
|
|
|
|
stopCamera,
|
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 09:42:58 -04:00
|
|
|
streamRef,
|
2026-08-14 21:20:43 -04:00
|
|
|
facingMode: activeFacingMode,
|
|
|
|
|
switchFacingMode,
|
2026-06-02 16:44:56 -04:00
|
|
|
};
|
|
|
|
|
}
|