'use client'; import { useEffect, useRef, useState } from 'react'; import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'; import CameraView from './CameraView'; interface ScannerProps { onScanSuccess: (decodedText: string) => void; onOCRMatch?: (text: string) => void; paused?: boolean; } export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerProps) { const html5QrCodeRef = useRef(null); const [isStarted, setIsStarted] = useState(false); const [error, setError] = useState(null); const [ocrProcessing, setOcrProcessing] = useState(false); const [countdown, setCountdown] = useState(4); const [zoom, setZoom] = useState(1); const [maxZoom, setMaxZoom] = useState(1); const [hasZoom, setHasZoom] = useState(false); const [detectedWords, setDetectedWords] = useState<{ text: string, bbox: { x0: number, y0: number, x1: number, y1: number } }[]>([]); const [isSelecting, setIsSelecting] = useState(false); const [capturedImage, setCapturedImage] = useState(null); const isBusy = useRef(false); const isTransitioning = useRef(false); // Flag to track start/stop transitions const scannerId = "reader-container-unique"; useEffect(() => { if (!html5QrCodeRef.current) { html5QrCodeRef.current = new Html5Qrcode(scannerId); } const startScanner = async () => { if (isBusy.current || isTransitioning.current) return; isBusy.current = true; isTransitioning.current = true; try { // If already scanning, we MUST stop it first if (html5QrCodeRef.current?.isScanning) { try { await html5QrCodeRef.current.stop(); } catch (e) { console.warn("Graceful stop failed, might be in transition:", e); // If it's already in transition, we just return and wait for the next effect trigger return; } } const config = { fps: 15, qrbox: { width: 320, height: 320 }, aspectRatio: 1.0, formatsToSupport: [ Html5QrcodeSupportedFormats.QR_CODE, Html5QrcodeSupportedFormats.CODE_128, Html5QrcodeSupportedFormats.CODE_39, Html5QrcodeSupportedFormats.EAN_13, Html5QrcodeSupportedFormats.UPC_A, Html5QrcodeSupportedFormats.DATA_MATRIX ], videoConstraints: { facingMode: "environment" } }; await html5QrCodeRef.current?.start( { facingMode: "environment" }, config, (decodedText) => { onScanSuccess(decodedText); }, () => {} // Ignore frame errors ); const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement; const track = (video?.srcObject as MediaStream)?.getVideoTracks()[0]; const caps = track?.getCapabilities() as any; // Debug logging console.log('[Zoom Detection Debug]', { videoFound: !!video, trackFound: !!track, trackState: track?.readyState, capabilitiesExists: !!caps, zoomCapability: caps?.zoom, zoomMax: caps?.zoom?.max, allCapabilities: Object.keys(caps || {}), }); if (track && caps?.zoom) { setHasZoom(true); setMaxZoom(caps.zoom.max || 3); console.log('[Zoom Detection SUCCESS] - hasZoom set to true, maxZoom:', caps.zoom.max || 3); } else { console.log('[Zoom Detection FAILED] - Camera does not support zoom capability'); } setIsStarted(true); setError(null); } catch (err: any) { const errorMsg = String(err); if (!errorMsg.includes("is already starting") && !errorMsg.includes("already under transition")) { console.error("Scanner failed", err); setError(errorMsg || "Failed to access camera"); setIsStarted(false); } } finally { isBusy.current = false; isTransitioning.current = false; } }; if (!paused) { startScanner(); } return () => { if (html5QrCodeRef.current?.isScanning) { isTransitioning.current = true; html5QrCodeRef.current.stop() .then(() => { setIsStarted(false); }) .catch(e => console.error("Unmount stop failed", e)) .finally(() => { isTransitioning.current = false; }); } }; }, [paused, onScanSuccess]); // Automated OCR Countdown Timer useEffect(() => { if (!isStarted || paused || isSelecting || ocrProcessing) { return; } const timer = setInterval(() => { setCountdown(prev => { if (prev <= 1) { handleOCR(); return 4; // Reset to 4 } return prev - 1; }); }, 1000); return () => clearInterval(timer); }, [isStarted, paused, isSelecting, ocrProcessing]); const handleOCR = async () => { if (ocrProcessing) return; setOcrProcessing(true); try { const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement; if (!video) return; const canvas = document.createElement('canvas'); const vWidth = video.videoWidth || 1280; const vHeight = video.videoHeight || 720; const cropFactor = 0.6; const sw = vWidth * cropFactor; const sh = vHeight * cropFactor; const sx = (vWidth - sw) / 2; const sy = (vHeight - sh) / 2; canvas.width = 1200; canvas.height = (sh / sw) * 1200; const ctx = canvas.getContext('2d'); if (ctx) { ctx.filter = 'grayscale(100%) contrast(180%) brightness(105%)'; ctx.drawImage(video, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height); } // Lazy-load tesseract.js only when OCR is actually needed const { createWorker } = await import('tesseract.js'); const workerPromise = createWorker('eng'); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("OCR Engine timeout")), 20000)); const worker = await Promise.race([workerPromise, timeoutPromise]) as any; const dataUrl = canvas.toDataURL('image/jpeg', 0.85); setCapturedImage(dataUrl); const result = await worker.recognize(dataUrl); const data = result.data; if (data && data.text && (!data.words || data.words.length === 0)) { onOCRMatch?.(data.text); await worker.terminate(); setCapturedImage(null); return; } if (!data || !data.words || data.words.length === 0) { await worker.terminate(); setCapturedImage(null); return; } const words = data.words.map((w: any) => ({ text: w.text, bbox: w.bbox })); setDetectedWords(words); setIsSelecting(true); await worker.terminate(); } catch (err) { console.error("OCR failed", err); } finally { setOcrProcessing(false); } }; const handleWordSelect = (text: string) => { onOCRMatch?.(text); setIsSelecting(false); setCapturedImage(null); setDetectedWords([]); setCountdown(4); // Restart countdown }; const handleCancelSelection = () => { setIsSelecting(false); setCapturedImage(null); setCountdown(4); }; const handleZoomChange = async (nextZoom: number) => { const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement; const track = (video?.srcObject as MediaStream)?.getVideoTracks()[0]; if (track) { await track.applyConstraints({ advanced: [{ zoom: nextZoom }] as any }); setZoom(nextZoom); } }; return ( ); }