Files
Stackq/src/lib/components/BarcodeScanDialog.svelte
T

183 lines
5.1 KiB
Svelte

<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import * as Dialog from "$lib/components/ui/dialog";
let {
open = $bindable(false),
onResult,
title = "Scan barcode",
}: {
open?: boolean;
onResult?: (value: string) => void;
title?: string;
} = $props();
let scanError = $state<string | null>(null);
let searchInput = $state("");
let videoEl = $state<HTMLVideoElement | undefined>(undefined);
let stream = $state<MediaStream | null>(null);
let scanRAF = $state(0);
function submitSearch() {
const value = searchInput.trim();
if (!value) return;
stopScan();
open = false;
searchInput = "";
onResult?.(value);
}
async function ensureBarcodeDetector(): Promise<boolean> {
if (typeof globalThis === "undefined" || "BarcodeDetector" in globalThis) return true;
try {
const mod = await import("barcode-detector-api-polyfill");
(globalThis as unknown as { BarcodeDetector: typeof BarcodeDetector }).BarcodeDetector =
mod.BarcodeDetector;
return true;
} catch {
return false;
}
}
async function startScan() {
scanError = null;
const hasDetector = await ensureBarcodeDetector();
if (!hasDetector) {
scanError = "Failed to load barcode scanner. Try again or use Chrome/Edge.";
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } },
});
} catch (e) {
scanError = e instanceof Error ? e.message : "Could not access camera.";
}
}
function stopScan() {
if (scanRAF) {
cancelAnimationFrame(scanRAF);
scanRAF = 0;
}
if (stream) {
stream.getTracks().forEach((t) => t.stop());
stream = null;
}
if (videoEl?.srcObject) {
videoEl.srcObject = null;
}
scanError = null;
}
$effect(() => {
if (!open) {
stopScan();
searchInput = "";
return;
}
startScan();
return () => stopScan();
});
$effect(() => {
if (!open || !stream || !videoEl) return;
videoEl.srcObject = stream;
videoEl.play().catch(() => {});
return () => {
if (videoEl?.srcObject) videoEl.srcObject = null;
};
});
const SCAN_INTERVAL_MS = 300;
function scanLoop() {
if (!videoEl || !("BarcodeDetector" in globalThis)) return;
const detector = new (globalThis as unknown as { BarcodeDetector: typeof BarcodeDetector }).BarcodeDetector();
let lastScan = 0;
async function detect() {
if (!open || !videoEl) return;
if (videoEl.readyState < 2) {
scanRAF = requestAnimationFrame(detect);
return;
}
const now = Date.now();
if (now - lastScan >= SCAN_INTERVAL_MS) {
lastScan = now;
try {
const barcodes = await detector.detect(videoEl);
if (barcodes.length > 0) {
const value = barcodes[0].rawValue?.trim() ?? "";
stopScan();
open = false;
onResult?.(value);
return;
}
} catch {
// ignore
}
}
scanRAF = requestAnimationFrame(detect);
}
detect();
}
$effect(() => {
if (!open || !stream || !videoEl) return;
scanLoop();
return () => {
if (scanRAF) cancelAnimationFrame(scanRAF);
};
});
</script>
<Dialog.Root bind:open>
<Dialog.Content
class="flex max-h-[100dvh] w-[100vw] max-w-none flex-col gap-4 rounded-none border-0 p-4 sm:max-h-[90dvh] sm:w-auto sm:max-w-md sm:rounded-lg sm:border"
onclose={() => stopScan()}
>
<Dialog.Header class="shrink-0">
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>Scan a barcode or type a code to look up an item.</Dialog.Description>
</Dialog.Header>
<div class="flex min-h-0 flex-1 flex-col gap-4">
<div class="flex shrink-0 gap-2">
<Input
type="text"
placeholder="Type barcode or SKU to search…"
bind:value={searchInput}
onkeydown={(e) => e.key === "Enter" && submitSearch()}
class="min-h-11 flex-1"
/>
<Button type="button" class="min-h-11 shrink-0" onclick={submitSearch} disabled={!searchInput.trim()}>
Search
</Button>
</div>
<p class="text-muted-foreground shrink-0 text-center text-xs">or use camera below</p>
{#if scanError}
<p class="text-destructive text-sm">{scanError}</p>
{:else if stream}
<video
bind:this={videoEl}
class="border-input max-h-[50vh] w-full flex-1 rounded-lg border object-cover sm:max-h-none"
muted
playsinline
></video>
{:else}
<p class="text-muted-foreground text-sm">Starting camera…</p>
{/if}
<div class="flex shrink-0 gap-2">
<Button
type="button"
variant="outline"
class="min-h-12 flex-1"
onclick={() => { stopScan(); open = false; }}
>
Cancel
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>