'use client'; import { useEffect, useRef, useState } from 'react'; import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'; import { RefreshCw, XCircle, Search } from 'lucide-react'; import { toast } from 'react-hot-toast'; 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; if (track && caps?.zoom) { setHasZoom(true); setMaxZoom(caps.zoom.max || 3); } 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 cn = (...classes: any[]) => classes.filter(Boolean).join(' '); return (
{/* Video Viewport Area */}
{isStarted && !paused && !isSelecting && (
)}
{/* Selection UI */} {isSelecting && capturedImage && (
{detectedWords.map((w, i) => (

Text Found

Tap any text to use it

)} {!isStarted && !error && (

Initializing camera...

)} {error && (

Camera Error

{error}

)}
{/* External Controls Area */}
{hasZoom && ( )}
{ocrProcessing ? ( <> Analyzing ) : ( <>
Smart Scan {countdown === 0 ? "Scanning" : `${countdown}s`}
)} {/* Visual Progress Bar */}

Scanner active ยท Use zoom or tap scan

); }