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';
import {
cardGuideToContainerStyle,
computeObjectCoverLayout,
videoBoundsToContainerStyle,
} from '../../lib/scanner-video-layout.js';
const TOAST_DURATION_MS = 2500;
function CardGuideFrame({ layout }) {
if (!layout) return null;
const style = cardGuideToContainerStyle(layout);
return (
);
}
function DetectionFrame({ card, layout }) {
const label =
card.status === 'scanned'
? `Card ${card.id} scanned`
: `Card ${card.id} identified`;
if (!layout) return null;
const style = videoBoundsToContainerStyle(card.bounds, layout);
return (
);
}
function ChromeIconButton({
onClick,
label,
pressed,
busy,
children,
className = '',
}) {
return (
);
}
function CameraViewport({
viewportRef,
videoRef,
isStreaming,
displayLayout,
foundCards,
}) {
return (
{isStreaming && displayLayout &&
}
{isStreaming &&
displayLayout &&
foundCards.map((card) => (
))}
{!isStreaming && (
)}
);
}
export default function ScannerCamera({
queue,
camera,
identification,
onBack = () => {},
onOpenCheckout = () => {},
onGalleryIdentify,
latestPeekCard = null,
cartCount,
isCheckoutOpen = false,
verificationPausedRef,
variant = 'default',
autoDetectOn = true,
}) {
const isWorkstation = variant === 'workstation';
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 viewportRef = useRef(null);
const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 });
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
}, []);
useEffect(() => {
const node = viewportRef.current;
if (!node) return undefined;
const syncSize = () => {
setViewportSize({
width: node.clientWidth,
height: node.clientHeight,
});
};
syncSize();
const observer = new ResizeObserver(syncSize);
observer.observe(node);
return () => observer.disconnect();
}, []);
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 displayLayout =
hasMetrics && viewportSize.width > 0 && viewportSize.height > 0
? computeObjectCoverLayout(
videoMetrics.width,
videoMetrics.height,
viewportSize.width,
viewportSize.height
)
: null;
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 (
{isWorkstation ? (
) : (
)}
{isStreaming && (
LIVE
)}
{isWorkstation && isStreaming && (
Auto-detect {autoDetectOn ? 'ON' : 'OFF'}
)}
Scan Cards
{galleryBusy ? (
) : (
)}
{isStreaming && (
{showFlash ? (
) : (
)}
{showFlash && (
)}
{scanStatus}
)}
{identification.disambiguation && (
)}
);
}