154 lines
5 KiB
Svelte
154 lines
5 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
|
|
interface Props {
|
|
ondetect: (barcode: string) => void;
|
|
onclose: () => void;
|
|
}
|
|
|
|
let { ondetect, onclose }: Props = $props();
|
|
|
|
let videoEl = $state<HTMLVideoElement | null>(null);
|
|
let error = $state<string | null>(null);
|
|
let stream: MediaStream | null = null;
|
|
let animFrame: number | null = null;
|
|
let detector: InstanceType<typeof BarcodeDetector> | null = null;
|
|
let detected = $state(false);
|
|
|
|
onMount(async () => {
|
|
// Use native BarcodeDetector if available, otherwise load polyfill (Safari, Firefox)
|
|
let BarcodeDetectorImpl: typeof BarcodeDetector;
|
|
if ('BarcodeDetector' in window) {
|
|
BarcodeDetectorImpl = BarcodeDetector;
|
|
} else {
|
|
const { BarcodeDetector: Polyfill } = await import('barcode-detector/ponyfill');
|
|
BarcodeDetectorImpl = Polyfill as unknown as typeof BarcodeDetector;
|
|
}
|
|
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
|
|
});
|
|
} catch {
|
|
error = 'Camera access denied. Please allow camera permission and try again.';
|
|
return;
|
|
}
|
|
|
|
if (!videoEl) return;
|
|
videoEl.srcObject = stream;
|
|
await videoEl.play();
|
|
|
|
detector = new BarcodeDetectorImpl({ formats: ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128', 'code_39', 'qr_code'] });
|
|
scanLoop();
|
|
});
|
|
|
|
onDestroy(cleanup);
|
|
|
|
function cleanup() {
|
|
if (animFrame !== null) cancelAnimationFrame(animFrame);
|
|
stream?.getTracks().forEach(t => t.stop());
|
|
}
|
|
|
|
async function scanLoop() {
|
|
if (!videoEl || !detector || videoEl.readyState < 2) {
|
|
animFrame = requestAnimationFrame(scanLoop);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const results = await detector.detect(videoEl);
|
|
if (results.length > 0 && !detected) {
|
|
detected = true;
|
|
cleanup();
|
|
ondetect(results[0].rawValue);
|
|
return;
|
|
}
|
|
} catch {
|
|
// frame not ready, keep scanning
|
|
}
|
|
|
|
animFrame = requestAnimationFrame(scanLoop);
|
|
}
|
|
</script>
|
|
|
|
<!-- Full-screen scanner overlay -->
|
|
<div class="fixed inset-0 z-50 bg-black flex flex-col">
|
|
<!-- Top bar -->
|
|
<div class="flex items-center justify-between px-4 pt-[calc(1rem+var(--safe-top))] pb-4">
|
|
<span class="text-sm font-medium text-white">Scan barcode</span>
|
|
<button
|
|
onclick={() => { cleanup(); onclose(); }}
|
|
class="w-9 h-9 flex items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors"
|
|
aria-label="Close scanner"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{#if error}
|
|
<div class="flex-1 flex items-center justify-center px-8 text-center">
|
|
<div>
|
|
<svg class="w-12 h-12 text-zinc-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01M5.07 19H19a2 2 0 001.75-2.97L13.75 4a2 2 0 00-3.5 0L3.25 16.03A2 2 0 005.07 19z" />
|
|
</svg>
|
|
<p class="text-white text-sm">{error}</p>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<!-- Camera feed -->
|
|
<div class="flex-1 relative overflow-hidden">
|
|
<!-- svelte-ignore a11y_media_has_caption -->
|
|
<video
|
|
bind:this={videoEl}
|
|
playsinline
|
|
muted
|
|
class="absolute inset-0 w-full h-full object-cover"
|
|
></video>
|
|
|
|
<!-- Dark overlay with cutout effect -->
|
|
<div class="absolute inset-0 flex items-center justify-center">
|
|
<div class="absolute inset-0 bg-black/50"></div>
|
|
|
|
<!-- Scan window -->
|
|
<div class="relative z-10 w-64 h-40">
|
|
<!-- Clear the overlay in the scan area -->
|
|
<div class="absolute inset-0 bg-transparent mix-blend-normal"></div>
|
|
|
|
<!-- Corner brackets -->
|
|
<div class="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-green-400 rounded-tl"></div>
|
|
<div class="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-green-400 rounded-tr"></div>
|
|
<div class="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-green-400 rounded-bl"></div>
|
|
<div class="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-green-400 rounded-br"></div>
|
|
|
|
<!-- Scanning line -->
|
|
{#if !detected}
|
|
<div class="absolute left-1 right-1 h-0.5 bg-green-400/80 rounded animate-scan"></div>
|
|
{:else}
|
|
<div class="absolute inset-0 bg-green-400/20 rounded flex items-center justify-center">
|
|
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hint -->
|
|
<div class="py-6 text-center pb-[calc(1.5rem+var(--safe-bottom))]">
|
|
<p class="text-zinc-400 text-sm">Point the camera at a barcode</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
@keyframes scan {
|
|
0%, 100% { top: 8px; }
|
|
50% { top: calc(100% - 8px); }
|
|
}
|
|
.animate-scan {
|
|
animation: scan 1.8s ease-in-out infinite;
|
|
}
|
|
</style>
|