354 lines
14 KiB
TypeScript
354 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
|
import { RefreshCw, XCircle, Search } from 'lucide-react';
|
|
import { createWorker } from 'tesseract.js';
|
|
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<Html5Qrcode | null>(null);
|
|
const [isStarted, setIsStarted] = useState(false);
|
|
const [error, setError] = useState<string | null>(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<string | null>(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.DATAMATRIX
|
|
],
|
|
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);
|
|
}
|
|
|
|
const workerPromise = createWorker('eng');
|
|
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("OCR Engine timeout")), 8000));
|
|
|
|
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 (
|
|
<div className="w-full max-w-md mx-auto flex flex-col gap-6">
|
|
{/* Video Viewport Area */}
|
|
<div className="relative w-full aspect-square overflow-hidden rounded-[2.5rem] shadow-2xl bg-black border-[3px] border-slate-800 shadow-blue-500/10">
|
|
<div className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center">
|
|
<div className="w-[320px] h-[320px] border-2 border-primary/50 rounded-3xl relative">
|
|
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-primary rounded-tl-xl" />
|
|
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-primary rounded-tr-xl" />
|
|
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-primary rounded-bl-xl" />
|
|
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-primary rounded-br-xl" />
|
|
{isStarted && !paused && !isSelecting && (
|
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-primary/50 shadow-[0_0_15px_rgba(59,130,246,0.8)] animate-scan-fast" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div id={scannerId} className="w-full aspect-square bg-slate-900" />
|
|
|
|
{/* Selection UI */}
|
|
{isSelecting && capturedImage && (
|
|
<div className="absolute inset-0 z-50 bg-slate-950 flex flex-col">
|
|
<div className="relative flex-1 bg-black flex items-center justify-center overflow-hidden">
|
|
<img src={capturedImage} className="max-w-full max-h-full object-contain" id="ocr-canvas-preview" />
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="relative" style={{ width: '100%', height: '100%' }}>
|
|
{detectedWords.map((w, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => handleWordSelect(w.text)}
|
|
className="absolute border border-primary bg-primary/20 rounded-sm active:bg-primary/50 transition-colors pointer-events-auto"
|
|
style={{
|
|
left: `${(w.bbox.x0 / 1600) * 100}%`,
|
|
top: `${(w.bbox.y0 / (1600 * (9/16))) * 100}%`,
|
|
width: `${((w.bbox.x1 - w.bbox.x0) / 1600) * 100}%`,
|
|
height: `${((w.bbox.y1 - w.bbox.y0) / (1600 * (9/16))) * 100}%`,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="p-6 bg-slate-900 border-t border-slate-800 flex flex-col gap-4">
|
|
<p className="text-sm font-bold text-center text-primary">Tap the correct text on the label</p>
|
|
<button
|
|
onClick={() => { setIsSelecting(false); setCapturedImage(null); setCountdown(4); }}
|
|
className="w-full py-4 bg-slate-800 text-white rounded-2xl font-bold text-xs"
|
|
>
|
|
Cancel & rescans
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!isStarted && !error && (
|
|
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-slate-900 text-slate-300 gap-4">
|
|
<RefreshCw className="w-8 h-8 animate-spin text-primary" />
|
|
<p className="text-sm font-medium">Initializing camera...</p>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-slate-900 text-slate-300 px-8 text-center gap-4">
|
|
<XCircle className="w-10 h-10 text-red-500" />
|
|
<div>
|
|
<p className="font-bold text-white">Camera Error</p>
|
|
<p className="text-xs text-slate-400 mt-1">{error}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className="mt-4 px-6 py-2 bg-slate-800 rounded-full text-sm font-bold hover:bg-slate-700 transition-colors"
|
|
>
|
|
Try Again
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* External Controls Area */}
|
|
<div className="flex flex-col gap-4 bg-slate-900/40 p-6 rounded-[2rem] border border-slate-800/50">
|
|
<div className="flex items-center gap-4 w-full">
|
|
{hasZoom && (
|
|
<button
|
|
onClick={async () => {
|
|
let nextZoom = 1;
|
|
if (zoom === 1) nextZoom = Math.min(2, maxZoom);
|
|
else if (zoom < maxZoom / 2) nextZoom = Math.floor(maxZoom / 2);
|
|
else if (zoom < maxZoom) nextZoom = maxZoom;
|
|
else nextZoom = 1;
|
|
|
|
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);
|
|
}
|
|
}}
|
|
className="h-16 px-6 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white rounded-2xl flex flex-col items-center justify-center shadow-lg transition-all active:scale-95"
|
|
>
|
|
<span className="text-xs font-black">{zoom.toFixed(1)}x</span>
|
|
<span className="text-[10px] text-primary font-bold">Zoom</span>
|
|
</button>
|
|
)}
|
|
|
|
<div className="flex-1 h-16 bg-slate-800/50 border border-slate-700 rounded-2xl flex items-center justify-center gap-3 px-4 relative overflow-hidden">
|
|
{ocrProcessing ? (
|
|
<>
|
|
<RefreshCw className="animate-spin text-primary" size={20} />
|
|
<span className="text-sm font-bold text-slate-200">Analyzing labels...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Search className={cn("text-slate-500", !isStarted && "opacity-20")} size={20} />
|
|
<div className="flex flex-col">
|
|
<span className="text-[10px] text-slate-500 font-bold leading-none">Label Scanning</span>
|
|
<span className="text-sm font-black text-primary leading-tight">
|
|
{countdown === 0 ? "Scanning..." : `Next scan in ${countdown}s`}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Visual Progress Bar */}
|
|
<div
|
|
className="absolute bottom-0 left-0 h-1 bg-primary/30 transition-all duration-1000 ease-linear"
|
|
style={{ width: `${((4 - countdown) / 4) * 100}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full flex justify-center items-center gap-2">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
|
<p className="text-[10px] text-slate-500 font-bold">
|
|
Barcode auto-scan active
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|