deckhearth/components/scanner/ScannerCamera.js

295 lines
10 KiB
JavaScript
Raw Normal View History

import { useCallback, useEffect, useRef, useState } from 'react';
import ScannerToast from './ScannerToast.js';
import ScannerCountPill from './ScannerCountPill.js';
import DeckModeIndicator from './DeckModeIndicator.js';
import ScannerDisambiguation from './ScannerDisambiguation.js';
import { useScannerSound } from '../../lib/use-scanner-sound.js';
import { useScannerFlash } from '../../lib/use-scanner-flash.js';
const TOAST_DURATION_MS = 2500;
function overlayBorderColor(status) {
if (status === 'scanned') return 'var(--color-info, #3B82F6)';
return 'var(--color-success, #10B981)';
}
function overlayGlowColor(status) {
if (status === 'scanned') return 'rgba(59, 130, 246, 0.31)';
return 'rgba(16, 185, 129, 0.31)';
}
export default function ScannerCamera({
queue,
camera,
identification,
deckMode,
onReview,
onStopSession,
sessionDestination,
}) {
const [toast, setToast] = useState({ message: '', visible: false, type: 'success' });
const toastTimerRef = useRef(null);
const prevCountRef = useRef(queue.scannedCards?.length ?? 0);
const {
videoRef,
canvasRef,
detectionCanvasRef,
isStreaming,
isDetecting,
trackedCards,
videoMetrics,
startCamera,
stopCamera,
streamRef,
} = camera;
const sound = useScannerSound();
// Requires useCameraScanner to expose streamRef — gracefully degrades to flash-unsupported if absent
const flash = useScannerFlash(streamRef);
// Auto-start camera on mount
useEffect(() => {
startCamera();
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount
}, []);
// React to newly scanned cards
const currentCount = queue.scannedCards?.length ?? 0;
useEffect(() => {
if (currentCount > prevCountRef.current) {
const newest = queue.scannedCards[0];
const cardName = newest?.name || newest?.card?.name || 'Card scanned';
navigator.vibrate?.(50);
sound.playSuccess();
clearTimeout(toastTimerRef.current);
setToast({ message: cardName, 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);
}, []);
const handleStop = useCallback(() => {
stopCamera();
onStopSession?.();
}, [stopCamera, onStopSession]);
const foundCards = trackedCards.filter(
(c) => c.status === 'confirmed' || c.status === 'scanned'
);
const hasMetrics = videoMetrics.width > 0 && videoMetrics.height > 0;
return (
<div className="flex flex-col w-full h-full gap-3">
{/* Camera viewport */}
<div
className="relative w-full overflow-hidden rounded-2xl flex-1"
style={{
minHeight: '60vh',
aspectRatio: '4 / 3',
backgroundColor: 'var(--bg-tertiary)',
}}
>
{/* Video */}
<video
ref={videoRef}
className="absolute inset-0 w-full h-full object-cover"
style={{ display: isStreaming ? 'block' : 'none' }}
autoPlay
playsInline
muted
aria-label="Card scanner camera feed"
/>
{/* Bounding box overlays */}
{isStreaming &&
hasMetrics &&
foundCards.map((card) => (
<div
key={card.id}
className="absolute rounded-lg transition-all duration-200 pointer-events-none"
style={{
left: `${(card.bounds.x / videoMetrics.width) * 100}%`,
top: `${(card.bounds.y / videoMetrics.height) * 100}%`,
width: `${(card.bounds.width / videoMetrics.width) * 100}%`,
height: `${(card.bounds.height / videoMetrics.height) * 100}%`,
borderWidth: 3,
borderStyle: 'solid',
borderColor: overlayBorderColor(card.status),
boxShadow: `0 0 15px ${overlayGlowColor(card.status)}`,
}}
>
<div
className="absolute -top-7 left-0 px-2.5 py-0.5 rounded-full text-xs font-bold text-white shadow-md whitespace-nowrap"
style={{ backgroundColor: overlayBorderColor(card.status) }}
>
{card.status === 'scanned' ? 'Scanned' : 'Found'}
</div>
</div>
))}
{/* LIVE indicator */}
{isStreaming && (
<div className="absolute top-3 right-3 z-10">
<div
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
style={{
backgroundColor: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
color: '#fff',
}}
>
<span
className="w-1.5 h-1.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--color-error, #EF4444)' }}
aria-hidden="true"
/>
LIVE
</div>
</div>
)}
{/* Toast */}
<ScannerToast
message={toast.message}
visible={toast.visible}
type={toast.type}
/>
{/* Bottom controls */}
{isStreaming && (
<div className="absolute bottom-4 left-0 right-0 z-10 flex items-center justify-center gap-4 px-4">
{/* Sound toggle */}
<button
type="button"
onClick={() => sound.setEnabled((v) => !v)}
className="w-11 h-11 rounded-full flex items-center justify-center shadow-lg transition-transform active:scale-95"
style={{
backgroundColor: 'rgba(0, 0, 0, 0.55)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
border: '2px solid rgba(255, 255, 255, 0.2)',
color: '#fff',
}}
aria-label={sound.enabled ? 'Mute scan sounds' : 'Unmute scan sounds'}
aria-pressed={sound.enabled}
>
{sound.enabled ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
)}
</button>
{/* Stop button */}
<button
type="button"
onClick={handleStop}
className="w-16 h-16 rounded-full flex items-center justify-center shadow-2xl transition-transform active:scale-95"
style={{
backgroundColor: 'rgba(239, 68, 68, 0.9)',
border: '3px solid rgba(255, 255, 255, 0.3)',
}}
aria-label="Stop scanning"
>
<div
className="w-6 h-6 rounded-sm"
style={{ backgroundColor: '#fff' }}
aria-hidden="true"
/>
</button>
{/* Flash toggle */}
{flash.flashSupported && (
<button
type="button"
onClick={flash.toggleFlash}
className="w-11 h-11 rounded-full flex items-center justify-center shadow-lg transition-transform active:scale-95"
style={{
backgroundColor: flash.flashOn
? 'rgba(255, 171, 64, 0.85)'
: 'rgba(0, 0, 0, 0.55)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
border: '2px solid rgba(255, 255, 255, 0.2)',
color: '#fff',
}}
aria-label={flash.flashOn ? 'Turn off flash' : 'Turn on flash'}
aria-pressed={flash.flashOn}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
</button>
)}
</div>
)}
{/* Loading state (before stream starts) */}
{!isStreaming && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div
className="w-10 h-10 rounded-full border-2 border-t-transparent animate-spin"
style={{ borderColor: 'var(--accent-ember)', borderTopColor: 'transparent' }}
aria-hidden="true"
/>
<span
className="text-sm font-medium"
style={{ color: 'var(--text-secondary)' }}
>
Starting camera
</span>
</div>
</div>
)}
</div>
{/* Hidden canvases for detection */}
<canvas ref={canvasRef} className="hidden" aria-hidden="true" />
<canvas ref={detectionCanvasRef} className="hidden" aria-hidden="true" />
{/* Count pill */}
<ScannerCountPill count={currentCount} onReview={onReview} />
{/* Deck mode progress */}
{deckMode && (
<DeckModeIndicator
current={currentCount}
target={deckMode.targetSize}
game={deckMode.game}
/>
)}
{/* Disambiguation overlay */}
{identification.disambiguation && (
<ScannerDisambiguation
disambiguation={identification.disambiguation}
submittingReview={identification.submittingReview}
onPick={identification.handleDisambiguationPick}
onNotInCatalog={identification.handleNotInCatalog}
onCancel={identification.cancelDisambiguation}
/>
)}
</div>
);
}