183 lines
5.1 KiB
Svelte
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>
|