feat(03-03): CameraCapture modal with getUserMedia
- Full-screen camera modal with live preview via getUserMedia - Photo capture using canvas.toBlob (JPEG at 0.9 quality) - Preview captured photo with retake/confirm flow - Camera stream properly stopped on close and after capture - Switch camera button for front/back camera toggle - Upload integration via ?/uploadImage form action Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
313
src/lib/components/CameraCapture.svelte
Normal file
313
src/lib/components/CameraCapture.svelte
Normal file
@@ -0,0 +1,313 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
|
||||
interface Props {
|
||||
entryId: string;
|
||||
onClose: () => void;
|
||||
onCapture?: (imageId: string) => void;
|
||||
}
|
||||
|
||||
let { entryId, onClose, onCapture }: Props = $props();
|
||||
|
||||
// Stream and capture state
|
||||
let stream: MediaStream | null = $state(null);
|
||||
let capturedBlob: Blob | null = $state(null);
|
||||
let capturedPreviewUrl: string | null = $state(null);
|
||||
let error: string | null = $state(null);
|
||||
let isUploading = $state(false);
|
||||
let facingMode: 'environment' | 'user' = $state('environment');
|
||||
|
||||
// Element refs
|
||||
let videoElement: HTMLVideoElement;
|
||||
let canvasElement: HTMLCanvasElement;
|
||||
|
||||
// Feature detection
|
||||
const hasCameraSupport =
|
||||
typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
|
||||
|
||||
const isSecureContext = typeof window !== 'undefined' && window.isSecureContext;
|
||||
|
||||
async function startCamera() {
|
||||
// Check browser support
|
||||
if (!hasCameraSupport) {
|
||||
error = 'Camera not supported in this browser. Use the file upload option instead.';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check secure context (HTTPS required except localhost)
|
||||
if (!isSecureContext) {
|
||||
error = 'Camera requires HTTPS connection.';
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop existing stream if any
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
|
||||
error = null;
|
||||
|
||||
// Set up timeout for camera start
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!stream) {
|
||||
error = 'Camera took too long to start. Please try again.';
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode,
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 }
|
||||
},
|
||||
audio: false
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = stream;
|
||||
await videoElement.play();
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (err instanceof DOMException) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
error = 'Camera access denied. Check browser settings to allow camera.';
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
error = 'No camera found on this device. Use file upload instead.';
|
||||
} else {
|
||||
error = 'Could not access camera: ' + err.message;
|
||||
}
|
||||
} else {
|
||||
error = 'Could not access camera.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function capturePhoto() {
|
||||
if (!videoElement || !canvasElement) return;
|
||||
|
||||
const ctx = canvasElement.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas dimensions to match video
|
||||
canvasElement.width = videoElement.videoWidth;
|
||||
canvasElement.height = videoElement.videoHeight;
|
||||
|
||||
// Draw current video frame to canvas
|
||||
ctx.drawImage(videoElement, 0, 0);
|
||||
|
||||
// Convert canvas to blob
|
||||
return new Promise<void>((resolve) => {
|
||||
canvasElement.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
capturedBlob = blob;
|
||||
capturedPreviewUrl = URL.createObjectURL(blob);
|
||||
// Stop camera to save battery while reviewing
|
||||
stopCamera();
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
'image/jpeg',
|
||||
0.9
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function retake() {
|
||||
// Revoke preview URL to free memory
|
||||
if (capturedPreviewUrl) {
|
||||
URL.revokeObjectURL(capturedPreviewUrl);
|
||||
}
|
||||
capturedBlob = null;
|
||||
capturedPreviewUrl = null;
|
||||
|
||||
// Restart camera
|
||||
await startCamera();
|
||||
}
|
||||
|
||||
async function confirmAndUpload() {
|
||||
if (!capturedBlob) return;
|
||||
|
||||
isUploading = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', capturedBlob, 'camera-capture.jpg');
|
||||
formData.append('entryId', entryId);
|
||||
|
||||
const response = await fetch('?/uploadImage', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.type === 'success' && result.data?.imageId) {
|
||||
onCapture?.(result.data.imageId);
|
||||
await invalidateAll();
|
||||
handleClose();
|
||||
} else {
|
||||
error = result.data?.error || 'Failed to upload photo.';
|
||||
isUploading = false;
|
||||
}
|
||||
} catch {
|
||||
error = 'Failed to upload photo. Please try again.';
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function switchCamera() {
|
||||
facingMode = facingMode === 'environment' ? 'user' : 'environment';
|
||||
startCamera();
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
stopCamera();
|
||||
|
||||
// Revoke any preview URL
|
||||
if (capturedPreviewUrl) {
|
||||
URL.revokeObjectURL(capturedPreviewUrl);
|
||||
capturedPreviewUrl = null;
|
||||
}
|
||||
|
||||
capturedBlob = null;
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Start camera on mount
|
||||
$effect(() => {
|
||||
startCamera();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
stopCamera();
|
||||
if (capturedPreviewUrl) {
|
||||
URL.revokeObjectURL(capturedPreviewUrl);
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-50 bg-black flex flex-col">
|
||||
<!-- Header with close and switch camera buttons -->
|
||||
<div class="flex justify-between items-center p-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClose}
|
||||
class="w-10 h-10 rounded-full bg-black/50 text-white flex items-center justify-center"
|
||||
aria-label="Close camera"
|
||||
>
|
||||
<svg class="w-6 h-6" 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>
|
||||
|
||||
{#if !error && !capturedPreviewUrl}
|
||||
<button
|
||||
type="button"
|
||||
onclick={switchCamera}
|
||||
class="w-10 h-10 rounded-full bg-black/50 text-white flex items-center justify-center"
|
||||
aria-label="Switch camera"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="flex-1 flex items-center justify-center overflow-hidden">
|
||||
{#if error}
|
||||
<div class="text-center p-6">
|
||||
<p class="text-white text-lg mb-4">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClose}
|
||||
class="px-6 py-3 bg-white text-black rounded-lg font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
{:else if capturedPreviewUrl}
|
||||
<!-- Preview captured photo -->
|
||||
<img
|
||||
src={capturedPreviewUrl}
|
||||
alt="Captured photo preview"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
{:else}
|
||||
<!-- Live camera view -->
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
class="max-h-full max-w-full object-contain"
|
||||
></video>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hidden canvas for capture -->
|
||||
<canvas bind:this={canvasElement} class="hidden"></canvas>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="p-4 pb-8 flex justify-center gap-4">
|
||||
{#if capturedPreviewUrl}
|
||||
<!-- Retake / Confirm buttons -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={retake}
|
||||
disabled={isUploading}
|
||||
class="px-6 py-3 bg-white/20 text-white rounded-lg font-medium disabled:opacity-50"
|
||||
>
|
||||
Retake
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={confirmAndUpload}
|
||||
disabled={isUploading}
|
||||
class="px-6 py-3 bg-white text-black rounded-lg font-medium disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? 'Uploading...' : 'Use Photo'}
|
||||
</button>
|
||||
{:else if !error}
|
||||
<!-- Capture button - large circle -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={capturePhoto}
|
||||
class="w-16 h-16 rounded-full bg-white border-4 border-white/50 flex items-center justify-center active:scale-95 transition-transform"
|
||||
aria-label="Capture photo"
|
||||
>
|
||||
<span class="w-12 h-12 rounded-full bg-white"></span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user