| <script lang="ts"> |
| import { onMount, onDestroy } from 'svelte'; |
| |
| interface Props { |
| onCapture: (file: File) => void; |
| } |
| |
| let { onCapture }: Props = $props(); |
| |
| let video: HTMLVideoElement; |
| let stream: MediaStream | null = null; |
| let error: string | null = $state(null); |
| let ready = $state(false); |
| |
| onMount(async () => { |
| try { |
| stream = await navigator.mediaDevices.getUserMedia({ |
| video: { facingMode: 'environment' }, |
| audio: false, |
| }); |
| video.srcObject = stream; |
| await video.play(); |
| ready = true; |
| } catch (err) { |
| error = |
| err instanceof DOMException && err.name === 'NotAllowedError' |
| ? 'Camera permission denied. Please allow camera access in your browser settings.' |
| : err instanceof Error |
| ? err.message |
| : 'Could not access camera.'; |
| } |
| }); |
| |
| onDestroy(() => { |
| stream?.getTracks().forEach((t) => t.stop()); |
| }); |
| |
| function capture() { |
| if (!ready || !video.videoWidth) return; |
| const canvas = document.createElement('canvas'); |
| canvas.width = video.videoWidth; |
| canvas.height = video.videoHeight; |
| const ctx = canvas.getContext('2d'); |
| if (!ctx) return; |
| ctx.drawImage(video, 0, 0); |
| canvas.toBlob( |
| (blob) => { |
| if (blob) { |
| const file = new File([blob], 'capture.jpg', { type: 'image/jpeg' }); |
| onCapture(file); |
| } |
| }, |
| 'image/jpeg', |
| 0.9, |
| ); |
| } |
| </script> |
|
|
| <div class="camera-container"> |
| {#if error} |
| <div class="camera-error"> |
| <div class="error-icon">📷</div> |
| <p>{error}</p> |
| </div> |
| {:else} |
| <video |
| bind:this={video} |
| class="camera-video" |
| autoplay |
| playsinline |
| muted |
| ></video> |
|
|
| {#if ready} |
| <div class="capture-overlay"> |
| <div class="reticle"></div> |
| <button class="capture-btn" onclick={capture} aria-label="Take photo"> |
| <div class="capture-ring"></div> |
| </button> |
| <p class="hint">Point at an object and tap to scan</p> |
| </div> |
| {:else} |
| <div class="camera-loading"> |
| <div class="spinner"></div> |
| <p>Starting camera…</p> |
| </div> |
| {/if} |
| {/if} |
| </div> |
|
|
| <style> |
| .camera-container { |
| position: relative; |
| width: 100%; |
| height: 100%; |
| background: #000; |
| overflow: hidden; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .camera-video { |
| width: 100%; |
| height: 100%; |
| object-fit: cover; |
| } |
| |
| .capture-overlay { |
| position: absolute; |
| top: 0; |
| left: 0; |
| right: 0; |
| bottom: 0; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: flex-end; |
| padding-bottom: 2rem; |
| pointer-events: none; |
| } |
| |
| .reticle { |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| width: 200px; |
| height: 200px; |
| border: 2px solid rgba(255, 255, 255, 0.4); |
| border-radius: 16px; |
| pointer-events: none; |
| } |
| |
| .capture-btn { |
| width: 72px; |
| height: 72px; |
| border: none; |
| background: none; |
| cursor: pointer; |
| padding: 0; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| pointer-events: auto; |
| -webkit-tap-highlight-color: transparent; |
| } |
| |
| .capture-ring { |
| width: 64px; |
| height: 64px; |
| border: 4px solid white; |
| border-radius: 50%; |
| background: rgba(255, 255, 255, 0.3); |
| transition: all 0.15s; |
| } |
| |
| .capture-btn:active .capture-ring { |
| transform: scale(0.85); |
| background: rgba(255, 255, 255, 0.6); |
| } |
| |
| .hint { |
| color: rgba(255, 255, 255, 0.7); |
| font-size: 0.85rem; |
| margin-top: 0.75rem; |
| text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); |
| } |
| |
| .camera-loading { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 1rem; |
| color: white; |
| } |
| |
| .spinner { |
| width: 40px; |
| height: 40px; |
| border: 3px solid rgba(255, 255, 255, 0.2); |
| border-top-color: white; |
| border-radius: 50%; |
| animation: spin 1s linear infinite; |
| } |
| |
| @keyframes spin { |
| to { transform: rotate(360deg); } |
| } |
| |
| .camera-error { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 1rem; |
| color: white; |
| text-align: center; |
| padding: 2rem; |
| } |
| |
| .error-icon { |
| font-size: 3rem; |
| } |
| </style> |
|
|