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>
294 lines
10 KiB
JavaScript
294 lines
10 KiB
JavaScript
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>
|
|
);
|
|
}
|