diff --git a/components/scanner/ScannerCamera.js b/components/scanner/ScannerCamera.js index ced464c..cb980ec 100644 --- a/components/scanner/ScannerCamera.js +++ b/components/scanner/ScannerCamera.js @@ -9,27 +9,44 @@ 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 }) { +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(' '); - return ( - + + ); + + if (!isManualMode) { + return ( + + ); + } + + return ( + ); } @@ -116,6 +158,41 @@ function DetectionFrame({ card, layout }) { ); } +function ShutterButton({ onClick, busy, label = 'Scan card' }) { + return ( + + ); +} + function ChromeIconButton({ onClick, label, @@ -160,6 +237,10 @@ function CameraViewport({ isStreaming, displayLayout, foundCards, + isManualMode, + isIdentifying, + onGuideTap, + manualScanDisabled, }) { return (
- {isStreaming && displayLayout && } + {isStreaming && displayLayout && ( + + )} {isStreaming && displayLayout && @@ -240,8 +329,13 @@ export default function ScannerCamera({ 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); @@ -339,12 +433,35 @@ export default function ScannerCamera({ 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 = isCheckoutOpen - ? 'Checkout open' - : isStreaming - ? 'Scanning…' - : 'Starting camera…'; + 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'; @@ -371,6 +488,10 @@ export default function ScannerCamera({ isStreaming={isStreaming} displayLayout={displayLayout} foundCards={foundCards} + isManualMode={isManualMode} + isIdentifying={isIdentifying} + onGuideTap={handleManualScan} + manualScanDisabled={manualScanDisabled} /> ) : ( @@ -380,6 +501,10 @@ export default function ScannerCamera({ isStreaming={isStreaming} displayLayout={displayLayout} foundCards={foundCards} + isManualMode={isManualMode} + isIdentifying={isIdentifying} + onGuideTap={handleManualScan} + manualScanDisabled={manualScanDisabled} /> )} @@ -584,18 +709,37 @@ export default function ScannerCamera({ )}
-

- {scanStatus} -

+ {isManualMode && !isWorkstation ? ( +
+ + + {scanStatus} + +
+ ) : ( +

+ {scanStatus} +

+ )} - +
+ +
)} diff --git a/lib/scanner-video-layout.js b/lib/scanner-video-layout.js index 308d3c9..d56d818 100644 --- a/lib/scanner-video-layout.js +++ b/lib/scanner-video-layout.js @@ -96,3 +96,29 @@ export function cardGuideToContainerStyle(layout) { height: `${(guide.height / layout.containerHeight) * 100}%`, }; } + +/** Map a container-space rectangle to video pixel bounds (inverse of object-cover). */ +export function containerRectToVideoBounds(rect, layout) { + const { videoWidth, videoHeight, renderedWidth, renderedHeight, offsetX, offsetY } = layout; + + const toVideoX = (containerX) => ((containerX - offsetX) / renderedWidth) * videoWidth; + const toVideoY = (containerY) => ((containerY - offsetY) / renderedHeight) * videoHeight; + + const x1 = toVideoX(rect.left); + const y1 = toVideoY(rect.top); + const x2 = toVideoX(rect.left + rect.width); + const y2 = toVideoY(rect.top + rect.height); + + const x = Math.max(0, Math.min(x1, x2)); + const y = Math.max(0, Math.min(y1, y2)); + const width = Math.min(videoWidth - x, Math.abs(x2 - x1)); + const height = Math.min(videoHeight - y, Math.abs(y2 - y1)); + + return { x, y, width, height }; +} + +/** Centered card guide converted to video pixel bounds. */ +export function layoutGuideToVideoBounds(layout) { + const guide = computeCardGuideBounds(layout.containerWidth, layout.containerHeight); + return containerRectToVideoBounds(guide, layout); +} diff --git a/lib/use-camera-scanner.js b/lib/use-camera-scanner.js index 24ae572..7abfcc8 100644 --- a/lib/use-camera-scanner.js +++ b/lib/use-camera-scanner.js @@ -3,6 +3,7 @@ import { detectCardShapesFromFrame, DETECTION_START_DELAY_MS, mergeDetectedShapesIntoTrackedCards, + MIN_STABLE_COUNT_FOR_VERIFY, selectCardsReadyForVerification, SHAPE_DETECTION_INTERVAL_MS, VERIFICATION_INTERVAL_MS, @@ -26,6 +27,7 @@ export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef, + autoDetectPausedRef, facingMode = 'environment', }) { const [isStreaming, setIsStreaming] = useState(false); @@ -174,6 +176,60 @@ export function useCameraScanner({ setTrackedCards(next); }, []); + const resetScanTrackers = useCallback(() => { + trackedCardsRef.current = []; + setTrackedCards([]); + nextCardIdRef.current = 1; + }, []); + + /** + * Manual scan: fresh detection pass, then verify the best candidate (or guide bounds). + * @returns {{ ok: boolean, reason?: string }} + */ + const triggerManualScan = useCallback( + (guideVideoBounds = null) => { + if (!isStreamingRef.current) { + return { ok: false, reason: 'not_streaming' }; + } + + if (verificationPausedRef?.current) { + return { ok: false, reason: 'paused' }; + } + + resetScanTrackers(); + + const shapes = detectCardShapes(); + updateTrackedCards(shapes); + + let target = trackedCardsRef.current + .filter((card) => card.status === 'detecting') + .sort((a, b) => b.stableCount - a.stableCount)[0]; + + if (!target && guideVideoBounds?.width > 0 && guideVideoBounds?.height > 0) { + target = { + id: nextCardIdRef.current++, + bounds: guideVideoBounds, + corners: null, + status: 'detecting', + firstSeen: Date.now(), + lastSeen: Date.now(), + stableCount: MIN_STABLE_COUNT_FOR_VERIFY, + scanAttempts: 0, + }; + trackedCardsRef.current = [target]; + setTrackedCards([target]); + } + + if (!target) { + return { ok: false, reason: 'no_card' }; + } + + onVerifyCard?.(target); + return { ok: true }; + }, + [onVerifyCard, resetScanTrackers, verificationPausedRef] + ); + const startDetection = () => { if (detectionIntervalRef.current || !isStreamingRef.current) return; @@ -188,6 +244,7 @@ export function useCameraScanner({ trackingIntervalRef.current = setInterval(() => { if (document.hidden) return; if (verificationPausedRef?.current) return; + if (autoDetectPausedRef?.current) return; const cardsToVerify = selectCardsReadyForVerification(trackedCardsRef.current); cardsToVerify.slice(0, 1).forEach((card) => { @@ -372,6 +429,8 @@ export function useCameraScanner({ startCamera, stopCamera, removeTrackedCard, + resetScanTrackers, + triggerManualScan, streamRef, facingMode: activeFacingMode, switchFacingMode, diff --git a/pages/scanner.js b/pages/scanner.js index dda5490..093164c 100644 --- a/pages/scanner.js +++ b/pages/scanner.js @@ -34,13 +34,17 @@ export default function Scanner() { const [inspectorCommitError, setInspectorCommitError] = useState(null); const [focusedCardId, setFocusedCardId] = useState(null); const [stripActiveTab, setStripActiveTab] = useState(TAB_RECENT); - const [isAutoDetectPaused, setIsAutoDetectPaused] = useState(false); + const [isAutoDetectPaused, setIsAutoDetectPaused] = useState(() => { + if (typeof window === 'undefined') return false; + return window.matchMedia('(max-width: 767px)').matches; + }); const [pageToast, setPageToast] = useState({ message: '', visible: false, type: 'success' }); const [galleryBusy, setGalleryBusy] = useState(false); const [batchBusy, setBatchBusy] = useState(false); const [batchProgress, setBatchProgress] = useState(null); const verificationPausedRef = useRef(false); + const autoDetectPausedRef = useRef(false); const prevCardCountRef = useRef(0); const scannedCardsRef = useRef([]); const toastTimerRef = useRef(null); @@ -58,6 +62,7 @@ export default function Scanner() { onError: (msg) => console.error('Camera error:', msg), onVerifyCard: (cardTracker) => onVerifyCardRef.current?.(cardTracker), verificationPausedRef, + autoDetectPausedRef, }); const identification = useScannerIdentification({ @@ -105,8 +110,9 @@ export default function Scanner() { verificationPausedRef.current = mobileCheckoutPauses || isListPickerOpen || - isAutoDetectPaused || Boolean(identification.disambiguation); + + autoDetectPausedRef.current = isAutoDetectPaused; }, [ isCheckoutOpen, isListPickerOpen, diff --git a/test/lib/scanner-video-layout.test.js b/test/lib/scanner-video-layout.test.js index 8423d99..1885772 100644 --- a/test/lib/scanner-video-layout.test.js +++ b/test/lib/scanner-video-layout.test.js @@ -3,6 +3,7 @@ import { cardGuideToContainerStyle, computeCardGuideBounds, computeObjectCoverLayout, + layoutGuideToVideoBounds, videoBoundsToContainerStyle, } from '../../lib/scanner-video-layout.js'; @@ -47,3 +48,14 @@ describe('card guide layout', () => { expect(style.width).toMatch(/%$/); }); }); + +describe('layoutGuideToVideoBounds', () => { + it('maps the centered guide back to video pixel space', () => { + const layout = computeObjectCoverLayout(1280, 720, 390, 844); + const bounds = layoutGuideToVideoBounds(layout); + expect(bounds.width).toBeGreaterThan(0); + expect(bounds.height).toBeGreaterThan(0); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.y).toBeGreaterThanOrEqual(0); + }); +});