145 lines
3.6 KiB
Svelte
145 lines
3.6 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
|
|
interface Props {
|
|
oncomplete: (base64: string, mediaType: string, thumbnailBase64: string) => void;
|
|
oncancel: () => void;
|
|
}
|
|
|
|
let { oncomplete, oncancel }: Props = $props();
|
|
|
|
let count = $state(3);
|
|
let videoElement: HTMLVideoElement | null = $state(null);
|
|
let stream: MediaStream | null = $state(null);
|
|
let error: string | null = $state(null);
|
|
let cancelled = false;
|
|
let countdownInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
async function startCamera() {
|
|
if (cancelled) return;
|
|
|
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
error = 'Camera not supported (requires HTTPS)';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const constraints: MediaStreamConstraints = {
|
|
video: {
|
|
facingMode: { ideal: 'environment' },
|
|
width: { ideal: 1920 },
|
|
height: { ideal: 1080 }
|
|
},
|
|
audio: false
|
|
};
|
|
|
|
stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
|
|
if (videoElement && !cancelled) {
|
|
videoElement.srcObject = stream;
|
|
startCountdown();
|
|
}
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : 'Camera access denied';
|
|
}
|
|
}
|
|
|
|
function stopCamera() {
|
|
if (countdownInterval) {
|
|
clearInterval(countdownInterval);
|
|
countdownInterval = null;
|
|
}
|
|
if (stream) {
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
stream = null;
|
|
}
|
|
}
|
|
|
|
function startCountdown() {
|
|
countdownInterval = setInterval(() => {
|
|
if (cancelled) {
|
|
stopCamera();
|
|
return;
|
|
}
|
|
count--;
|
|
if (count === 0) {
|
|
if (countdownInterval) clearInterval(countdownInterval);
|
|
capture();
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function capture() {
|
|
if (cancelled || !videoElement) {
|
|
stopCamera();
|
|
return;
|
|
}
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = videoElement.videoWidth;
|
|
canvas.height = videoElement.videoHeight;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {
|
|
stopCamera();
|
|
oncancel();
|
|
return;
|
|
}
|
|
|
|
ctx.drawImage(videoElement, 0, 0);
|
|
|
|
const base64 = canvas.toDataURL('image/jpeg', 0.85).split(',')[1];
|
|
const mediaType = 'image/jpeg';
|
|
|
|
const thumbMaxSize = 800;
|
|
const scale = Math.min(thumbMaxSize / canvas.width, thumbMaxSize / canvas.height, 1);
|
|
const thumbCanvas = document.createElement('canvas');
|
|
thumbCanvas.width = Math.round(canvas.width * scale);
|
|
thumbCanvas.height = Math.round(canvas.height * scale);
|
|
const thumbCtx = thumbCanvas.getContext('2d');
|
|
if (thumbCtx) {
|
|
thumbCtx.drawImage(canvas, 0, 0, thumbCanvas.width, thumbCanvas.height);
|
|
}
|
|
const thumbnailBase64 = thumbCanvas.toDataURL('image/jpeg', 0.7).split(',')[1];
|
|
|
|
stopCamera();
|
|
oncomplete(base64, mediaType, thumbnailBase64);
|
|
}
|
|
|
|
function handleCancel() {
|
|
cancelled = true;
|
|
stopCamera();
|
|
oncancel();
|
|
}
|
|
|
|
onMount(() => {
|
|
startCamera();
|
|
return () => {
|
|
cancelled = true;
|
|
stopCamera();
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<div class="fixed inset-0 z-50 flex flex-col bg-black" data-camera-ui>
|
|
{#if error}
|
|
<div class="flex flex-1 flex-col items-center justify-center p-4">
|
|
<p class="mb-4 text-center text-sm text-red-400">{error}</p>
|
|
<button onclick={handleCancel} class="rounded bg-neutral-700 px-4 py-2 text-sm text-white">
|
|
Close
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div class="relative flex-1">
|
|
<video bind:this={videoElement} autoplay playsinline muted class="h-full w-full object-cover"
|
|
></video>
|
|
<div class="absolute inset-0 flex items-center justify-center">
|
|
<span class="text-8xl font-bold text-white drop-shadow-lg">{count}</span>
|
|
</div>
|
|
</div>
|
|
<div class="p-4 text-center">
|
|
<button onclick={handleCancel} class="text-sm text-neutral-400">Cancel</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|