piclets / src /lib /components /Scanner /CameraCapture.svelte
Fraser's picture
Mine filter by discoverer_username + camera-only scanner
3f82f76
Raw
History Blame Contribute Delete
4.34 kB
<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>