"use client" import * as React from "react" import { BrowserCodeReader, BrowserMultiFormatReader, BrowserQRCodeReader, type IScannerControls, } from "@zxing/browser" import { BarcodeFormat, DecodeHintType, NotFoundException, type Result, } from "@zxing/library" import { CameraIcon, CameraOffIcon, CheckIcon, ChevronDownIcon, ClipboardIcon, FlashlightIcon, FlashlightOffIcon, HistoryIcon, ImageIcon, PauseIcon, PlayIcon, RefreshCwIcon, ScanBarcodeIcon, Trash2Icon, UploadIcon, XIcon, } from "lucide-react" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" export type ScannerMode = "barcode" | "qr" export type ScannerStatus = "idle" | "starting" | "scanning" | "paused" | "success" | "error" export type ScannerPermission = "prompt" | "granted" | "denied" | "unsupported" export type ScannerDevice = { deviceId: string label: string groupId?: string } export type ScannerRegion = { x: number y: number width: number height: number } export type ScannerHistoryItem = { id: string value: string format: string rawBytes?: Uint8Array source: "camera" | "image" deviceId?: string createdAt: number } export type BarcodeScannerSnapshot = { status: ScannerStatus permission: ScannerPermission running: boolean paused: boolean torchSupported: boolean torchEnabled: boolean deviceId?: string devices: ScannerDevice[] history: ScannerHistoryItem[] lastResult: ScannerHistoryItem | null } export type BarcodeScannerHandle = { start: () => Promise stop: () => void pause: () => void resume: () => Promise retry: () => Promise listDevices: () => Promise switchDevice: (deviceId: string) => Promise toggleTorch: (enabled?: boolean) => Promise decodeFile: (file: File) => Promise clearHistory: () => void removeHistoryItem: (id: string) => void copyResult: (id?: string) => Promise getSnapshot: () => BarcodeScannerSnapshot video: HTMLVideoElement | null } export type BarcodeScannerStateContent = { idle?: React.ReactNode | ((start: () => Promise) => React.ReactNode) permissionDenied?: React.ReactNode | ((error: Error, retry: () => Promise) => React.ReactNode) error?: React.ReactNode | ((error: Error, retry: () => Promise) => React.ReactNode) emptyHistory?: React.ReactNode } export type BarcodeScannerToolbarContext = BarcodeScannerSnapshot & { mode: ScannerMode selectedFormats: BarcodeFormat[] actions: BarcodeScannerHandle } export type BarcodeScannerHistoryContext = BarcodeScannerSnapshot & { actions: BarcodeScannerHandle } export type BarcodeScannerProps = Omit, "onError"> & { mode?: ScannerMode deviceId?: string defaultDeviceId?: string onDeviceIdChange?: (deviceId: string) => void facingMode?: "user" | "environment" preferredWidth?: number preferredHeight?: number preferredFrameRate?: number paused?: boolean defaultPaused?: boolean onPausedChange?: (paused: boolean) => void formats?: BarcodeFormat[] tryHarder?: boolean scanRegion?: ScannerRegion scanDelay?: number duplicateWindowMs?: number stopAfterResult?: boolean pauseAfterResult?: boolean resumeDelayMs?: number maxHistory?: number history?: ScannerHistoryItem[] defaultHistory?: ScannerHistoryItem[] onHistoryChange?: (history: ScannerHistoryItem[]) => void allowTorch?: boolean allowImageUpload?: boolean showDeviceSelect?: boolean showControls?: boolean showHistory?: boolean showResultOverlay?: boolean autoStart?: boolean beep?: boolean vibrate?: boolean | number[] onResult?: (item: ScannerHistoryItem, result: Result) => void onError?: (error: Error) => void onStatusChange?: (status: ScannerStatus) => void onPermissionChange?: (permission: ScannerPermission) => void onDevicesChange?: (devices: ScannerDevice[]) => void onTorchChange?: (enabled: boolean, supported: boolean) => void renderToolbar?: (context: BarcodeScannerToolbarContext) => React.ReactNode renderHistory?: (context: BarcodeScannerHistoryContext) => React.ReactNode stateContent?: BarcodeScannerStateContent beforePreview?: React.ReactNode afterPreview?: React.ReactNode overlay?: React.ReactNode videoClassName?: string previewClassName?: string toolbarClassName?: string historyClassName?: string labels?: { start?: string stop?: string pause?: string resume?: string retry?: string camera?: string noCamera?: string permissionError?: string scanning?: string paused?: string uploadImage?: string history?: string clearHistory?: string copy?: string remove?: string torchOn?: string torchOff?: string noResult?: string } } function useControllableState({ value, defaultValue, onChange, }: { value: T | undefined defaultValue: T onChange?: (value: T) => void }) { const [internalValue, setInternalValue] = React.useState(defaultValue) const controlled = value !== undefined const currentValue = controlled ? value : internalValue const setValue = React.useCallback((nextValue: T) => { if (!controlled) setInternalValue(nextValue) onChange?.(nextValue) }, [controlled, onChange]) return [currentValue, setValue] as const } function createError(cause: unknown, fallback: string) { return cause instanceof Error ? cause : new Error(fallback) } function mapPermission(error: Error): ScannerPermission { if (error.name === "NotAllowedError" || error.name === "SecurityError") return "denied" if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) return "unsupported" return "prompt" } function getFormatName(result: Result) { try { return BarcodeFormat[result.getBarcodeFormat()] ?? String(result.getBarcodeFormat()) } catch { return "UNKNOWN" } } function createReader(mode: ScannerMode, formats: BarcodeFormat[] | undefined, tryHarder: boolean) { if (mode === "qr" && (!formats || formats.length === 0 || (formats.length === 1 && formats[0] === BarcodeFormat.QR_CODE))) { return new BrowserQRCodeReader() } const hints = new Map() if (formats?.length) hints.set(DecodeHintType.POSSIBLE_FORMATS, formats) if (tryHarder) hints.set(DecodeHintType.TRY_HARDER, true) return new BrowserMultiFormatReader(hints) } function createHistoryItem(result: Result, source: "camera" | "image", deviceId?: string): ScannerHistoryItem { return { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, value: result.getText(), format: getFormatName(result), rawBytes: result.getRawBytes() ?? undefined, source, deviceId: deviceId || undefined, createdAt: Date.now(), } } function BarcodeScannerComponent({ mode = "barcode", deviceId, defaultDeviceId = "", onDeviceIdChange, facingMode = "environment", preferredWidth = 1920, preferredHeight = 1080, preferredFrameRate = 30, paused, defaultPaused = false, onPausedChange, formats, tryHarder = true, scanRegion = { x: 12, y: 18, width: 76, height: 64 }, scanDelay = 250, duplicateWindowMs = 1500, stopAfterResult = false, pauseAfterResult = false, resumeDelayMs = 900, maxHistory = 20, history, defaultHistory = [], onHistoryChange, allowTorch = true, allowImageUpload = true, showDeviceSelect = true, showControls = true, showHistory = true, showResultOverlay = true, autoStart = true, beep = false, vibrate = false, onResult, onError, onStatusChange, onPermissionChange, onDevicesChange, onTorchChange, renderToolbar, renderHistory, stateContent, beforePreview, afterPreview, overlay, className, videoClassName, previewClassName, toolbarClassName, historyClassName, labels, ...props }: BarcodeScannerProps, forwardedRef: React.ForwardedRef) { const videoRef = React.useRef(null) const fileInputRef = React.useRef(null) const controlsRef = React.useRef(null) const readerRef = React.useRef(null) const lastResultRef = React.useRef<{ value: string; timestamp: number } | null>(null) const lastScanRef = React.useRef(0) const resumeTimerRef = React.useRef(null) const [devices, setDevices] = React.useState([]) const [status, setStatusState] = React.useState("idle") const [permission, setPermissionState] = React.useState("prompt") const [error, setError] = React.useState(null) const [torchSupported, setTorchSupported] = React.useState(false) const [torchEnabled, setTorchEnabled] = React.useState(false) const [lastResult, setLastResult] = React.useState(null) const [selectedDeviceId, setSelectedDeviceId] = useControllableState({ value: deviceId, defaultValue: defaultDeviceId, onChange: onDeviceIdChange, }) const [currentPaused, setCurrentPaused] = useControllableState({ value: paused, defaultValue: defaultPaused, onChange: onPausedChange, }) const [currentHistory, setCurrentHistory] = useControllableState({ value: history, defaultValue: defaultHistory, onChange: onHistoryChange, }) const setStatus = React.useCallback((nextStatus: ScannerStatus) => { setStatusState(nextStatus) onStatusChange?.(nextStatus) }, [onStatusChange]) const setPermission = React.useCallback((nextPermission: ScannerPermission) => { setPermissionState(nextPermission) onPermissionChange?.(nextPermission) }, [onPermissionChange]) const stop = React.useCallback(() => { if (resumeTimerRef.current != null) window.clearTimeout(resumeTimerRef.current) resumeTimerRef.current = null controlsRef.current?.stop() controlsRef.current = null readerRef.current = null const stream = videoRef.current?.srcObject if (stream instanceof MediaStream) stream.getTracks().forEach((track) => track.stop()) if (videoRef.current) videoRef.current.srcObject = null setTorchSupported(false) setTorchEnabled(false) setStatus("idle") }, [setStatus]) const listDevices = React.useCallback(async () => { if (!navigator.mediaDevices?.enumerateDevices) { setPermission("unsupported") throw new Error("MediaDevices API is unavailable") } const inputs = await BrowserCodeReader.listVideoInputDevices() const nextDevices = inputs.map((item, index) => ({ deviceId: item.deviceId, groupId: item.groupId, label: item.label || `${labels?.camera ?? "Camera"} ${index + 1}`, })) setDevices(nextDevices) onDevicesChange?.(nextDevices) if (!selectedDeviceId && nextDevices[0]) setSelectedDeviceId(nextDevices[0].deviceId) return nextDevices }, [labels?.camera, onDevicesChange, selectedDeviceId, setPermission, setSelectedDeviceId]) const emitFeedback = React.useCallback(() => { if (vibrate && navigator.vibrate) navigator.vibrate(vibrate === true ? 80 : vibrate) if (beep && typeof AudioContext !== "undefined") { const context = new AudioContext() const oscillator = context.createOscillator() const gain = context.createGain() oscillator.frequency.value = 880 gain.gain.value = 0.04 oscillator.connect(gain) gain.connect(context.destination) oscillator.start() oscillator.stop(context.currentTime + 0.08) oscillator.addEventListener("ended", () => void context.close(), { once: true }) } }, [beep, vibrate]) const appendResult = React.useCallback((result: Result, source: "camera" | "image") => { const now = Date.now() if (source === "camera" && now - lastScanRef.current < Math.max(0, scanDelay)) return null if (source === "camera") lastScanRef.current = now const item = createHistoryItem(result, source, selectedDeviceId) const previous = lastResultRef.current if (previous?.value === item.value && now - previous.timestamp < duplicateWindowMs) return null lastResultRef.current = { value: item.value, timestamp: now } const nextHistory = [item, ...currentHistory].slice(0, Math.max(maxHistory, 1)) setCurrentHistory(nextHistory) setLastResult(item) setStatus("success") emitFeedback() onResult?.(item, result) return item }, [currentHistory, duplicateWindowMs, emitFeedback, maxHistory, onResult, scanDelay, selectedDeviceId, setCurrentHistory, setStatus]) const updateTorchSupport = React.useCallback(() => { const controls = controlsRef.current as (IScannerControls & { switchTorch?: (onOff: boolean) => Promise }) | null const supported = typeof controls?.switchTorch === "function" setTorchSupported(supported) if (!supported) setTorchEnabled(false) onTorchChange?.(supported ? torchEnabled : false, supported) }, [onTorchChange, torchEnabled]) const start = React.useCallback(async () => { if (!navigator.mediaDevices?.getUserMedia) { const nextError = new Error("Camera scanning is unsupported in this browser") setPermission("unsupported") setError(nextError) setStatus("error") onError?.(nextError) throw nextError } if (typeof window !== "undefined" && !window.isSecureContext) { const nextError = new Error("Camera access requires HTTPS or localhost") setPermission("denied") setError(nextError) setStatus("error") onError?.(nextError) throw nextError } stop() setStatus("starting") setError(null) const reader = createReader(mode, formats, tryHarder) readerRef.current = reader try { const constraints: MediaStreamConstraints = { audio: false, video: selectedDeviceId ? { deviceId: { exact: selectedDeviceId } } : { facingMode: { ideal: facingMode }, width: { ideal: preferredWidth }, height: { ideal: preferredHeight }, frameRate: { ideal: preferredFrameRate }, }, } const controls = selectedDeviceId ? await reader.decodeFromVideoDevice(selectedDeviceId, videoRef.current, (result, scanError) => { if (result) { const item = appendResult(result, "camera") if (!item) return if (stopAfterResult) stop() else if (pauseAfterResult) { setCurrentPaused(true) controlsRef.current?.stop() setStatus("paused") resumeTimerRef.current = window.setTimeout(() => { setCurrentPaused(false) }, resumeDelayMs) } else setStatus("scanning") } else if (scanError && !(scanError instanceof NotFoundException)) { onError?.(createError(scanError, "Barcode decoding failed")) } }) : await reader.decodeFromConstraints(constraints, videoRef.current, (result, scanError) => { if (result) { const item = appendResult(result, "camera") if (!item) return if (stopAfterResult) stop() else if (pauseAfterResult) { setCurrentPaused(true) controlsRef.current?.stop() setStatus("paused") resumeTimerRef.current = window.setTimeout(() => setCurrentPaused(false), resumeDelayMs) } else setStatus("scanning") } else if (scanError && !(scanError instanceof NotFoundException)) { onError?.(createError(scanError, "Barcode decoding failed")) } }) controlsRef.current = controls setPermission("granted") setStatus("scanning") await listDevices().catch(() => []) updateTorchSupport() } catch (cause: unknown) { const nextError = createError(cause, "Camera could not be started") setPermission(mapPermission(nextError)) setError(nextError) setStatus("error") onError?.(nextError) throw nextError } }, [appendResult, facingMode, formats, listDevices, mode, onError, pauseAfterResult, preferredFrameRate, preferredHeight, preferredWidth, resumeDelayMs, selectedDeviceId, setCurrentPaused, setPermission, setStatus, stop, stopAfterResult, tryHarder, updateTorchSupport]) const pause = React.useCallback(() => { setCurrentPaused(true) controlsRef.current?.stop() controlsRef.current = null setStatus("paused") }, [setCurrentPaused, setStatus]) const resume = React.useCallback(async () => { setCurrentPaused(false) await start() }, [setCurrentPaused, start]) const retry = React.useCallback(async () => { setError(null) await start() }, [start]) const switchDevice = React.useCallback(async (nextDeviceId: string) => { setSelectedDeviceId(nextDeviceId) stop() await Promise.resolve() }, [setSelectedDeviceId, stop]) const toggleTorch = React.useCallback(async (enabled?: boolean) => { if (!allowTorch) return false const controls = controlsRef.current as (IScannerControls & { switchTorch?: (onOff: boolean) => Promise }) | null if (!controls?.switchTorch) return false const nextEnabled = enabled ?? !torchEnabled await controls.switchTorch(nextEnabled) setTorchEnabled(nextEnabled) onTorchChange?.(nextEnabled, true) return nextEnabled }, [allowTorch, onTorchChange, torchEnabled]) const decodeFile = React.useCallback(async (file: File) => { const reader = createReader(mode, formats, tryHarder) const url = URL.createObjectURL(file) try { const result = await reader.decodeFromImageUrl(url) const item = appendResult(result, "image") if (!item) throw new Error("Duplicate result was suppressed") return item } catch (cause: unknown) { const nextError = createError(cause, "No supported code was found in the image") setError(nextError) onError?.(nextError) throw nextError } finally { URL.revokeObjectURL(url) } }, [appendResult, formats, mode, onError, tryHarder]) const removeHistoryItem = React.useCallback((id: string) => { setCurrentHistory(currentHistory.filter((item) => item.id !== id)) if (lastResult?.id === id) setLastResult(null) }, [currentHistory, lastResult?.id, setCurrentHistory]) const clearHistory = React.useCallback(() => { setCurrentHistory([]) setLastResult(null) }, [setCurrentHistory]) const copyResult = React.useCallback(async (id?: string) => { const item = id ? currentHistory.find((entry) => entry.id === id) : lastResult if (!item) return await navigator.clipboard.writeText(item.value) }, [currentHistory, lastResult]) const getSnapshot = React.useCallback((): BarcodeScannerSnapshot => ({ status, permission, running: status === "scanning" || status === "success", paused: currentPaused, torchSupported, torchEnabled, deviceId: selectedDeviceId || undefined, devices, history: currentHistory, lastResult, }), [currentHistory, currentPaused, devices, lastResult, permission, selectedDeviceId, status, torchEnabled, torchSupported]) const actions = React.useMemo(() => ({ start, stop, pause, resume, retry, listDevices, switchDevice, toggleTorch, decodeFile, clearHistory, removeHistoryItem, copyResult, getSnapshot, video: videoRef.current, }), [clearHistory, copyResult, decodeFile, getSnapshot, listDevices, pause, removeHistoryItem, resume, retry, start, stop, switchDevice, toggleTorch]) React.useImperativeHandle(forwardedRef, () => actions, [actions]) React.useEffect(() => { if (!navigator.mediaDevices?.addEventListener) return const handleDeviceChange = () => void listDevices().catch(() => []) navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange) return () => navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange) }, [listDevices]) React.useEffect(() => { if (!autoStart || currentPaused) return void start().catch(() => undefined) return stop }, [autoStart, currentPaused, selectedDeviceId, start, stop]) React.useEffect(() => stop, [stop]) const snapshot = getSnapshot() const toolbarContext: BarcodeScannerToolbarContext = { ...snapshot, mode, selectedFormats: formats ?? (mode === "qr" ? [BarcodeFormat.QR_CODE] : []), actions, } const historyContext: BarcodeScannerHistoryContext = { ...snapshot, actions } const defaultToolbar = (
{showDeviceSelect && devices.length > 1 ? ( ) : {status === "scanning" ? labels?.scanning ?? "Scanning" : labels?.paused ?? "Scanner paused"}} {allowTorch && torchSupported ? ( ) : null}
{showControls ? (
{allowImageUpload ? ( ) : null} {status === "scanning" || status === "success" ? ( ) : currentPaused || status === "paused" ? ( ) : ( )} {(status === "scanning" || status === "success" || status === "paused") ? ( ) : null}
) : null}
) const defaultHistory = (

{labels?.history ?? "Scan history"}{currentHistory.length}

{currentHistory.length ? : null}
{currentHistory.length ? (
{currentHistory.map((item) => (
{item.source === "image" ? : }

{item.value}

{item.format} ยท {new Date(item.createdAt).toLocaleTimeString()}

))}
) : stateContent?.emptyHistory ??

{labels?.noResult ?? "No codes scanned yet"}

}
) return (
{beforePreview}
) } const BarcodeScanner = React.forwardRef(BarcodeScannerComponent) BarcodeScanner.displayName = "BarcodeScanner" export type QrScannerProps = Omit const QrScanner = React.forwardRef(function QrScanner(props, ref) { return }) QrScanner.displayName = "QrScanner" export { BarcodeScanner, QrScanner }