deckhearth/components/scanner/ScannerCamera.js

762 lines
24 KiB
JavaScript
Raw Normal View History

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,
layoutGuideToVideoBounds,
videoBoundsToContainerStyle,
} from '../../lib/scanner-video-layout.js';
const TOAST_DURATION_MS = 2500;
function CardGuideFrame({ layout, onTap, isManualMode, isIdentifying, tapDisabled }) {
if (!layout) return null;
const style = cardGuideToContainerStyle(layout);
const className = [
'absolute scan-guide',
isManualMode ? 'scan-guide-tappable' : 'pointer-events-none',
isIdentifying ? 'scan-guide-active' : '',
]
.filter(Boolean)
.join(' ');
const frame = (
<>
<span className="scan-bracket scan-bracket-tl" />
<span className="scan-bracket scan-bracket-tr" />
<span className="scan-bracket scan-bracket-bl" />
<span className="scan-bracket scan-bracket-br" />
<style jsx>{`
.scan-guide {
transition: box-shadow 0.2s ease, transform 0.15s ease;
}
.scan-guide-tappable {
cursor: pointer;
touch-action: manipulation;
}
.scan-guide-tappable:active:not(:disabled) {
transform: scale(0.985);
}
.scan-guide-active {
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent-flame) 55%, transparent);
}
.scan-bracket {
position: absolute;
width: 28px;
height: 28px;
border-color: color-mix(in srgb, var(--text-primary) 70%, transparent);
border-style: solid;
}
.scan-guide-active .scan-bracket {
border-color: var(--accent-ember);
}
.scan-bracket-tl { top: 0; left: 0; border-width: 2px 0 0 2px; }
.scan-bracket-tr { top: 0; right: 0; border-width: 2px 2px 0 0; }
.scan-bracket-bl { bottom: 0; left: 0; border-width: 0 0 2px 2px; }
.scan-bracket-br { bottom: 0; right: 0; border-width: 0 2px 2px 0; }
`}</style>
</>
);
if (!isManualMode) {
return (
<div className={className} aria-hidden="true" style={style}>
{frame}
</div>
);
}
return (
<button
type="button"
className={className}
style={{ ...style, background: 'transparent', padding: 0, border: 'none' }}
onClick={onTap}
disabled={tapDisabled}
aria-label="Tap to scan card in frame"
aria-busy={isIdentifying || undefined}
>
{frame}
</button>
);
}
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 (
<div
className="absolute pointer-events-none scan-frame"
role="status"
aria-live="polite"
aria-label={label}
style={style}
>
<span className="scan-bracket scan-bracket-tl" aria-hidden="true" />
<span className="scan-bracket scan-bracket-tr" aria-hidden="true" />
<span className="scan-bracket scan-bracket-bl" aria-hidden="true" />
<span className="scan-bracket scan-bracket-br" aria-hidden="true" />
<span className="scan-line" aria-hidden="true" />
<style jsx>{`
.scan-bracket {
position: absolute;
width: 22px;
height: 22px;
border-color: var(--accent-ember);
border-style: solid;
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--accent-flame) 55%, transparent));
}
.scan-bracket-tl { top: 0; left: 0; border-width: 3px 0 0 3px; }
.scan-bracket-tr { top: 0; right: 0; border-width: 3px 3px 0 0; }
.scan-bracket-bl { bottom: 0; left: 0; border-width: 0 0 3px 3px; }
.scan-bracket-br { bottom: 0; right: 0; border-width: 0 3px 3px 0; }
.scan-line {
position: absolute;
left: 8%;
right: 8%;
height: 2px;
top: 0;
background: linear-gradient(
90deg,
transparent,
var(--accent-ember),
var(--accent-flame),
var(--accent-ember),
transparent
);
box-shadow: 0 0 10px color-mix(in srgb, var(--accent-flame) 70%, transparent);
animation: scan-sweep 1.6s var(--motion-ease-linear, linear) infinite;
}
@keyframes scan-sweep {
0% { top: 8%; opacity: 0.35; }
50% { top: 88%; opacity: 1; }
100% { top: 8%; opacity: 0.35; }
}
@media (prefers-reduced-motion: reduce) {
.scan-line {
animation: none;
top: 50%;
opacity: 0.85;
}
}
`}</style>
</div>
);
}
function ShutterButton({ onClick, busy, label = 'Scan card' }) {
return (
<button
type="button"
onClick={onClick}
disabled={busy}
className="relative w-14 h-14 rounded-full flex items-center justify-center flex-shrink-0 transition-transform active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-60"
style={{
minWidth: 56,
minHeight: 56,
backgroundColor: 'color-mix(in srgb, var(--text-primary) 12%, transparent)',
border: '3px solid var(--text-primary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
aria-label={label}
aria-busy={busy || undefined}
>
{busy ? (
<div
className="w-5 h-5 rounded-full border-2 border-t-transparent animate-spin"
style={{ borderColor: 'var(--accent-ember)', borderTopColor: 'transparent' }}
aria-hidden="true"
/>
) : (
<span
className="w-10 h-10 rounded-full"
style={{ backgroundColor: 'var(--text-primary)' }}
aria-hidden="true"
/>
)}
</button>
);
}
function ChromeIconButton({
onClick,
label,
pressed,
busy,
children,
className = '',
}) {
return (
<button
type="button"
onClick={onClick}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-transform active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${className}`}
style={{
minWidth: 44,
minHeight: 44,
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
aria-label={label}
aria-pressed={pressed}
aria-busy={busy || undefined}
disabled={busy}
>
<GlassSurface
as="span"
tint="mid"
rim="subtle"
blur="mid"
className="w-full h-full rounded-full flex items-center justify-center"
style={{ color: 'var(--text-primary)' }}
>
{children}
</GlassSurface>
</button>
);
}
function CameraViewport({
viewportRef,
videoRef,
isStreaming,
displayLayout,
foundCards,
isManualMode,
isIdentifying,
onGuideTap,
manualScanDisabled,
}) {
return (
<div
ref={viewportRef}
className="absolute inset-0"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
<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"
/>
{isStreaming && displayLayout && (
<CardGuideFrame
layout={displayLayout}
isManualMode={isManualMode}
isIdentifying={isIdentifying}
onTap={onGuideTap}
tapDisabled={manualScanDisabled}
/>
)}
{isStreaming &&
displayLayout &&
foundCards.map((card) => (
<DetectionFrame key={card.id} card={card} layout={displayLayout} />
))}
{!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>
);
}
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,
triggerManualScan,
} = camera;
const isIdentifying = Boolean(identification.isIdentifying);
const isManualMode = !autoDetectOn;
const manualScanDisabled = isIdentifying || isCheckoutOpen || Boolean(identification.disambiguation);
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 handleManualScan = useCallback(() => {
if (manualScanDisabled || !triggerManualScan) return;
const guideBounds = displayLayout ? layoutGuideToVideoBounds(displayLayout) : null;
const result = triggerManualScan(guideBounds);
if (result?.ok) {
navigator.vibrate?.(30);
return;
}
if (result?.reason === 'no_card') {
clearTimeout(toastTimerRef.current);
setToast({ message: 'Hold the card in the frame', visible: true, type: 'info' });
toastTimerRef.current = setTimeout(() => {
setToast((current) => ({ ...current, visible: false }));
}, TOAST_DURATION_MS);
}
}, [displayLayout, manualScanDisabled, triggerManualScan]);
const showFlash = flash.flashSupported && facingMode === 'environment';
const scanStatus = (() => {
if (isCheckoutOpen) return 'Checkout open';
if (!isStreaming) return 'Starting camera…';
if (isIdentifying) return 'Identifying card…';
if (isManualMode) return 'Tap frame or scan button';
return 'Scanning…';
})();
const switchCameraLabel =
facingMode === 'environment' ? 'Switch to front camera' : 'Switch to rear camera';
return (
<div
className={`relative w-full overflow-hidden ${
isWorkstation
? 'md:rounded-xl md:aspect-video md:min-h-0 md:max-h-[min(56vh,640px)]'
: 'min-h-[100dvh] max-md:rounded-none max-md:min-h-[100dvh]'
}`}
>
{isWorkstation ? (
<GlassSurface
tint="low"
rim="subtle"
blur="mid"
className="absolute inset-0 md:rounded-xl overflow-hidden"
style={{ boxShadow: 'var(--rim-light-inner), var(--ember-rim-subtle)' }}
>
<CameraViewport
viewportRef={viewportRef}
videoRef={videoRef}
isStreaming={isStreaming}
displayLayout={displayLayout}
foundCards={foundCards}
isManualMode={isManualMode}
isIdentifying={isIdentifying}
onGuideTap={handleManualScan}
manualScanDisabled={manualScanDisabled}
/>
</GlassSurface>
) : (
<CameraViewport
viewportRef={viewportRef}
videoRef={videoRef}
isStreaming={isStreaming}
displayLayout={displayLayout}
foundCards={foundCards}
isManualMode={isManualMode}
isIdentifying={isIdentifying}
onGuideTap={handleManualScan}
manualScanDisabled={manualScanDisabled}
/>
)}
{isStreaming && (
<div className={`absolute top-3 right-3 z-10 ${isWorkstation ? 'md:hidden' : ''}`}>
<GlassSurface
tint="mid"
rim="subtle"
blur="mid"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
style={{ color: 'var(--text-primary)' }}
>
<span
className="w-1.5 h-1.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--color-error)' }}
aria-hidden="true"
/>
LIVE
</GlassSurface>
</div>
)}
{isWorkstation && isStreaming && (
<div className="absolute top-3 left-3 z-10 hidden md:block">
<GlassSurface
tint="mid"
rim="subtle"
blur="mid"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
style={{ color: 'var(--text-primary)' }}
role="status"
aria-live="polite"
>
<span
className={`w-1.5 h-1.5 rounded-full ${autoDetectOn ? 'motion-safe:animate-pulse' : ''}`}
style={{
backgroundColor: autoDetectOn
? 'var(--color-success)'
: 'var(--text-secondary)',
}}
aria-hidden="true"
/>
Auto-detect {autoDetectOn ? 'ON' : 'OFF'}
</GlassSurface>
</div>
)}
<ScannerToast
message={toast.message}
visible={toast.visible}
type={toast.type}
/>
<div className={isWorkstation ? 'md:hidden' : undefined}>
<ScannerScanPeek
key={latestPeekCard?.id ?? latestPeekCard?.name ?? 'peek'}
card={latestPeekCard}
onOpenCheckout={onOpenCheckout}
/>
</div>
<div
className={`absolute top-0 left-0 right-0 z-30 px-3 pb-2 ${isWorkstation ? 'md:hidden' : ''}`}
style={{ paddingTop: 'calc(0.75rem + env(safe-area-inset-top, 0px))' }}
>
<GlassSurface
tint="mid"
rim="subtle"
blur="mid"
className="flex items-center justify-between gap-2 rounded-2xl px-2 py-2"
>
<ChromeIconButton onClick={onBack} label="Leave scanner">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M19 12H5" />
<path d="M12 19l-7-7 7-7" />
</svg>
</ChromeIconButton>
<h1
className="text-base font-semibold truncate"
style={{ color: 'var(--text-primary)' }}
>
Scan Cards
</h1>
<ChromeIconButton
onClick={handleGalleryClick}
label="Choose image from gallery"
busy={galleryBusy}
>
{galleryBusy ? (
<div
className="w-4 h-4 rounded-full border-2 border-t-transparent animate-spin"
style={{ borderColor: 'var(--accent-ember)', borderTopColor: 'transparent' }}
aria-hidden="true"
/>
) : (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
)}
</ChromeIconButton>
</GlassSurface>
</div>
<input
ref={galleryInputRef}
type="file"
accept="image/*"
className="sr-only"
tabIndex={-1}
onChange={handleGalleryChange}
/>
{isStreaming && (
<div
className={`absolute bottom-0 left-0 right-0 z-30 px-3 pt-2 ${isWorkstation ? 'md:hidden' : ''}`}
style={{ paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom, 0px))' }}
>
<GlassSurface
tint="mid"
rim="subtle"
blur="mid"
className="flex items-center justify-between gap-2 rounded-2xl px-2 py-2"
>
<div className="flex items-center gap-2">
{showFlash ? (
<ChromeIconButton
onClick={flash.toggleFlash}
label={flash.flashOn ? 'Turn off flash' : 'Turn on flash'}
pressed={flash.flashOn}
className={flash.flashOn ? 'ring-2' : ''}
>
<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>
</ChromeIconButton>
) : (
<ChromeIconButton onClick={switchFacingMode} label={switchCameraLabel}>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M11 19H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h6" />
<path d="M13 5h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-6" />
<path d="M8 12h8" />
<path d="m16 9 3 3-3 3" />
<path d="m8 15-3-3 3-3" />
</svg>
</ChromeIconButton>
)}
{showFlash && (
<ChromeIconButton onClick={switchFacingMode} label={switchCameraLabel}>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M11 19H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h6" />
<path d="M13 5h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-6" />
<path d="M8 12h8" />
<path d="m16 9 3 3-3 3" />
<path d="m8 15-3-3 3-3" />
</svg>
</ChromeIconButton>
)}
</div>
{isManualMode && !isWorkstation ? (
<div className="flex flex-col items-center gap-1 flex-shrink-0 px-1">
<ShutterButton
onClick={handleManualScan}
busy={isIdentifying}
label={isIdentifying ? 'Identifying card' : 'Scan card'}
/>
<span
className="text-[10px] font-medium text-center max-w-[88px] leading-tight"
style={{ color: 'var(--text-secondary)' }}
aria-live="polite"
>
{scanStatus}
</span>
</div>
) : (
<p
className="text-xs font-medium truncate text-center flex-1 px-1"
style={{ color: 'var(--text-secondary)' }}
aria-live="polite"
>
{scanStatus}
</p>
)}
<div className="flex flex-1 min-w-0 justify-end">
<ScannerCountPill
count={resolvedCartCount}
onOpenCheckout={onOpenCheckout}
/>
</div>
</GlassSurface>
</div>
)}
<canvas ref={canvasRef} className="hidden" aria-hidden="true" />
<canvas ref={detectionCanvasRef} className="hidden" aria-hidden="true" />
{identification.disambiguation && (
<ScannerDisambiguation
disambiguation={identification.disambiguation}
submittingReview={identification.submittingReview}
onPick={identification.handleDisambiguationPick}
onNotInCatalog={identification.handleNotInCatalog}
onCancel={identification.cancelDisambiguation}
/>
)}
</div>
);
}