{"version":3,"file":"use-barcode-scanner.cjs","names":[],"sources":["../../src/capture/use-barcode-scanner.ts"],"sourcesContent":["/**\n * @tempest-limits hook-lines — the scan loop, the repeat suppression window and the\n * torch control share the same track and the same interval handle; splitting them\n * would leave a timer running against a track another hook stopped.\n */\nimport { useEffect, useRef, useState, type RefObject } from \"react\";\n\nimport { useStableCallback } from \"@/hooks/use-stable-callback\";\nimport {\n    useCameraStream,\n    type CameraStreamError,\n    type CameraStreamStatus,\n} from \"@/vision/use-camera-stream\";\n\nimport {\n    createBarcodeDetector,\n    getSupportedBarcodeFormats,\n    isBarcodeDetectionSupported,\n    normalizeBarcode,\n    DEFAULT_BARCODE_FORMATS,\n    type BarcodeDetectorLike,\n    type BarcodeFormat,\n    type BarcodeScanResult,\n} from \"./barcode\";\nimport { useTorch, type UseTorchResult } from \"./use-torch\";\n\n/** Options for {@link useBarcodeScanner}. */\nexport interface UseBarcodeScannerOptions {\n    /** Symbologies to look for. Defaults to {@link DEFAULT_BARCODE_FORMATS}. */\n    formats?: readonly BarcodeFormat[];\n    /** Called for every accepted read — that is, after repeat suppression. */\n    onScan?: (result: BarcodeScanResult) => void;\n    /**\n     * How often a frame is examined, in ms. Default 200.\n     *\n     * Not `requestAnimationFrame`: decoding is 10–40 ms of main-thread work on a\n     * phone, so running it per frame competes with the preview it is reading from and\n     * makes the video stutter. Five looks per second is faster than a human can aim.\n     */\n    intervalMs?: number;\n    /**\n     * Ignore the **same** value again for this long, in ms. Default 2500.\n     *\n     * A symbol stays in frame for as long as the user holds the camera there, so a\n     * scanner without this fires the same code five times a second — which, wired to\n     * \"add item to cart\", is a bug the user pays for. A *different* value is never\n     * suppressed.\n     */\n    repeatDelayMs?: number;\n    /** Stop looking without releasing the camera — a confirmation sheet is open. */\n    paused?: boolean;\n    /**\n     * A decoder to use instead of the native one.\n     *\n     * The way to support Safari and Firefox: hand in a polyfill and everything else\n     * here works unchanged. See {@link isBarcodeDetectionSupported} for why the SDK\n     * does not bundle one.\n     */\n    detector?: BarcodeDetectorLike;\n    /** Camera constraints, forwarded to `useCameraStream`. Defaults to the rear camera. */\n    constraints?: MediaStreamConstraints;\n    /**\n     * A frame the engine refused to decode.\n     *\n     * Not \"nothing found\" — that resolves to an empty list and is the normal case.\n     * This is the engine itself failing, which the loop survives because it is\n     * usually transient (a frame arriving between two resolutions).\n     */\n    onError?: (error: unknown) => void;\n}\n\n/** Value returned by {@link useBarcodeScanner}. */\nexport interface UseBarcodeScannerResult {\n    /** Attach to a `<video ref={…} muted playsInline />`. */\n    videoRef: RefObject<HTMLVideoElement | null>;\n    /** Camera lifecycle. `\"ready\"` means the preview is running. */\n    status: CameraStreamStatus;\n    /** Classified camera error, or `null`. */\n    error: CameraStreamError | null;\n    /** `false` when there is no decoder — no native API and none injected. */\n    supported: boolean;\n    /**\n     * Formats actually in use: the requested ones intersected with what the engine\n     * reports. Empty while the probe is in flight, or when nothing matched.\n     */\n    formats: readonly BarcodeFormat[];\n    /** Whether the detect loop is running right now. */\n    scanning: boolean;\n    /** The most recent accepted read, or `null`. */\n    result: BarcodeScanResult | null;\n    /** The LED torch of this camera, when it has one. */\n    torch: UseTorchResult;\n    /** Re-open the camera after an error (the user changed the permission). */\n    retry: () => void;\n}\n\n/** A video is decodable once it has data for the current frame and a real size. */\nfunction frameIsReady(video: HTMLVideoElement): boolean {\n    return video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0;\n}\n\n/**\n * Read barcodes and QR codes from the camera.\n *\n * The camera and its classified errors come from `useCameraStream`, so this hook is\n * only the decoding half: it drives a `BarcodeDetector` over the preview on an\n * interval, suppresses the same value repeating, and exposes the torch.\n *\n * **Mounting this opens the camera.** It inherits that from `useCameraStream`, which\n * acquires on mount — so mount it *after* the user asks to scan (a button that reveals\n * the scanner), never on a page that merely contains one. A permission prompt nobody\n * provoked is the most reliable way to earn a permanent block, after which\n * `getUserMedia` rejects without ever prompting again.\n *\n * `supported` deserves a branch in the UI, not an assertion: `BarcodeDetector` is\n * Chromium-only and missing on Windows/Linux desktop, Firefox and everything on iOS.\n * Inject a `detector` to cover those, or tell the user to type the code.\n *\n * @param options - See {@link UseBarcodeScannerOptions}.\n * @returns The camera plumbing plus the scan state.\n *\n * @example\n * const scanner = useBarcodeScanner({\n *     formats: [\"ean_13\"],\n *     onScan: ({ rawValue }) => addToCart(rawValue),\n * });\n * return <video ref={scanner.videoRef} muted playsInline />;\n */\nexport function useBarcodeScanner(options: UseBarcodeScannerOptions = {}): UseBarcodeScannerResult {\n    const {\n        formats: requested = DEFAULT_BARCODE_FORMATS,\n        onScan,\n        intervalMs = 200,\n        repeatDelayMs = 2500,\n        paused = false,\n        detector: injected,\n        constraints,\n        onError,\n    } = options;\n\n    const [supported, setSupported] = useState(\n        () => injected !== undefined || isBarcodeDetectionSupported(),\n    );\n\n    /**\n     * No decoder, no camera.\n     *\n     * Opening the camera only to report \"this browser cannot decode barcodes\" spends a\n     * permission prompt on nothing — and a refusal is permanent, so it also spends the\n     * *next* feature that needs the camera.\n     */\n    const camera = useCameraStream({ constraints, enabled: supported });\n    const torch = useTorch(camera.stream);\n\n    const [formats, setFormats] = useState<readonly BarcodeFormat[]>(\n        injected !== undefined ? requested : [],\n    );\n    const [scanning, setScanning] = useState(false);\n    const [result, setResult] = useState<BarcodeScanResult | null>(null);\n\n    const detectorRef = useRef<BarcodeDetectorLike | null>(null);\n    const lastValue = useRef<string | null>(null);\n    const lastAt = useRef(0);\n\n    const emitScan = useStableCallback((scan: BarcodeScanResult) => onScan?.(scan));\n    const emitError = useStableCallback((error: unknown) => onError?.(error));\n\n    const requestedKey = requested.join(\",\");\n\n    /**\n     * Resolve which formats the engine will take, then build the detector.\n     *\n     * The intersection is not defensive coding: `new BarcodeDetector({ formats })`\n     * throws `NotSupportedError` when any entry is unknown to the platform decoder, and\n     * that list differs between two Chromium builds on two operating systems. Asking\n     * for the intersection is the only way one call site works everywhere.\n     */\n    useEffect(() => {\n        if (injected) {\n            detectorRef.current = injected;\n            setSupported(true);\n            setFormats(requested);\n            return;\n        }\n        if (!isBarcodeDetectionSupported()) {\n            detectorRef.current = null;\n            setSupported(false);\n            setFormats([]);\n            return;\n        }\n        let cancelled = false;\n        void getSupportedBarcodeFormats().then((available) => {\n            if (cancelled) return;\n            const usable =\n                available.length === 0\n                    ? requested\n                    : requested.filter((format) => available.includes(format));\n            const detector = usable.length > 0 ? createBarcodeDetector(usable) : null;\n            detectorRef.current = detector;\n            setFormats(detector ? usable : []);\n            setSupported(detector !== null);\n        });\n        return () => {\n            cancelled = true;\n        };\n        // `requestedKey` stands in for the array identity, so a caller passing an\n        // inline `formats={[\"ean_13\"]}` does not rebuild the detector every render.\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [injected, requestedKey]);\n\n    /**\n     * Look at a frame every `intervalMs`, and never overlap two looks.\n     *\n     * The loop re-arms itself *after* each `detect()` settles rather than running on a\n     * fixed `setInterval`: decoding sometimes takes longer than the interval, and an\n     * interval would then queue calls faster than the engine drains them until the tab\n     * is unusable.\n     */\n    useEffect(() => {\n        if (!supported || paused || camera.status !== \"ready\") {\n            setScanning(false);\n            return;\n        }\n        let stopped = false;\n        let timer: ReturnType<typeof setTimeout> | undefined;\n        setScanning(true);\n\n        const accept = (scan: BarcodeScanResult): void => {\n            const now = Date.now();\n            const isRepeat =\n                scan.rawValue === lastValue.current && now - lastAt.current < repeatDelayMs;\n            if (isRepeat) return;\n            lastValue.current = scan.rawValue;\n            lastAt.current = now;\n            setResult(scan);\n            emitScan(scan);\n        };\n\n        const look = async (): Promise<void> => {\n            const video = camera.videoRef.current;\n            const detector = detectorRef.current;\n            if (!video || !detector || !frameIsReady(video)) return;\n            try {\n                const found = await detector.detect(video);\n                if (stopped) return;\n                for (const raw of found) {\n                    const scan = normalizeBarcode(raw);\n                    if (scan.rawValue !== \"\") accept(scan);\n                }\n            } catch (error) {\n                if (!stopped) emitError(error);\n            }\n        };\n\n        const tick = (): void => {\n            void look().finally(() => {\n                if (!stopped) timer = setTimeout(tick, intervalMs);\n            });\n        };\n        tick();\n\n        return () => {\n            stopped = true;\n            if (timer !== undefined) clearTimeout(timer);\n            setScanning(false);\n        };\n    }, [\n        supported,\n        paused,\n        camera.status,\n        camera.videoRef,\n        intervalMs,\n        repeatDelayMs,\n        emitScan,\n        emitError,\n    ]);\n\n    return {\n        videoRef: camera.videoRef,\n        status: camera.status,\n        error: camera.error,\n        supported,\n        formats,\n        scanning,\n        result,\n        torch,\n        retry: camera.retry,\n    };\n}\n"],"mappings":"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"}