feat: implement camera capture step
Add in-app camera capture to the upload flow. Guests can now take photos and record videos directly via getUserMedia without leaving the app. The captured media is immediately queued through the existing IndexedDB upload pipeline alongside library-picked files. - CameraCapture.svelte: fullscreen overlay with live preview, photo capture (JPEG via canvas), video recording (WebM/MP4 via MediaRecorder), front/back camera toggle, recording timer, and permission-denied error state - Upload page: side-by-side "Gallery" and "Camera" pickers; shared caption/hashtags fields apply to both sources; Blob→File conversion with timestamped filename before enqueue - .env.test: reference environment config for local testing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
238
frontend/src/lib/components/CameraCapture.svelte
Normal file
238
frontend/src/lib/components/CameraCapture.svelte
Normal file
@@ -0,0 +1,238 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
oncapture: (blob: Blob, type: 'photo' | 'video') => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { oncapture, onclose }: Props = $props();
|
||||
|
||||
let videoEl: HTMLVideoElement = $state()!;
|
||||
let canvasEl: HTMLCanvasElement = $state()!;
|
||||
let stream: MediaStream | null = $state(null);
|
||||
let facingMode = $state<'environment' | 'user'>('environment');
|
||||
let recording = $state(false);
|
||||
let recordingTime = $state(0);
|
||||
let error = $state<string | null>(null);
|
||||
let mediaRecorder: MediaRecorder | null = null;
|
||||
let recordedChunks: Blob[] = [];
|
||||
let recordingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMount(() => {
|
||||
startCamera();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stopCamera();
|
||||
if (recordingInterval) clearInterval(recordingInterval);
|
||||
});
|
||||
|
||||
async function startCamera() {
|
||||
error = null;
|
||||
stopCamera();
|
||||
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode, width: { ideal: 1920 }, height: { ideal: 1080 } },
|
||||
audio: true
|
||||
});
|
||||
if (videoEl) {
|
||||
videoEl.srcObject = stream;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'NotAllowedError') {
|
||||
error = 'Kamerazugriff wurde verweigert. Bitte erlaube den Zugriff in den Browsereinstellungen.';
|
||||
} else if (err instanceof DOMException && err.name === 'NotFoundError') {
|
||||
error = 'Keine Kamera gefunden.';
|
||||
} else {
|
||||
error = 'Kamera konnte nicht gestartet werden.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (stream) {
|
||||
for (const track of stream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
stream = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCamera() {
|
||||
facingMode = facingMode === 'environment' ? 'user' : 'environment';
|
||||
await startCamera();
|
||||
}
|
||||
|
||||
function capturePhoto() {
|
||||
if (!videoEl || !canvasEl) return;
|
||||
const ctx = canvasEl.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
canvasEl.width = videoEl.videoWidth;
|
||||
canvasEl.height = videoEl.videoHeight;
|
||||
ctx.drawImage(videoEl, 0, 0);
|
||||
|
||||
canvasEl.toBlob(
|
||||
(blob) => {
|
||||
if (blob) oncapture(blob, 'photo');
|
||||
},
|
||||
'image/jpeg',
|
||||
0.92
|
||||
);
|
||||
}
|
||||
|
||||
function startRecording() {
|
||||
if (!stream) return;
|
||||
|
||||
recordedChunks = [];
|
||||
recordingTime = 0;
|
||||
|
||||
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
|
||||
? 'video/webm;codecs=vp9'
|
||||
: MediaRecorder.isTypeSupported('video/webm')
|
||||
? 'video/webm'
|
||||
: 'video/mp4';
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType });
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) recordedChunks.push(e.data);
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(recordedChunks, { type: mediaRecorder?.mimeType ?? mimeType });
|
||||
oncapture(blob, 'video');
|
||||
recordedChunks = [];
|
||||
};
|
||||
|
||||
mediaRecorder.start(1000);
|
||||
recording = true;
|
||||
|
||||
recordingInterval = setInterval(() => {
|
||||
recordingTime += 1;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
recording = false;
|
||||
if (recordingInterval) {
|
||||
clearInterval(recordingInterval);
|
||||
recordingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRecordingTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-50 flex flex-col bg-black">
|
||||
<!-- Camera preview -->
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
{#if error}
|
||||
<div class="flex h-full items-center justify-center p-8">
|
||||
<div class="rounded-lg bg-gray-900 p-6 text-center">
|
||||
<svg class="mx-auto mb-3 h-12 w-12 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 10l-4 4m0-4l4 4m6-4a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-sm text-white">{error}</p>
|
||||
<button
|
||||
onclick={onclose}
|
||||
class="mt-4 rounded-lg bg-white/20 px-4 py-2 text-sm text-white"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
class="h-full w-full object-cover {facingMode === 'user' ? 'scale-x-[-1]' : ''}"
|
||||
></video>
|
||||
|
||||
{#if recording}
|
||||
<div class="absolute left-4 top-4 flex items-center gap-2 rounded-full bg-red-600 px-3 py-1">
|
||||
<div class="h-2 w-2 animate-pulse rounded-full bg-white"></div>
|
||||
<span class="text-sm font-medium text-white">{formatRecordingTime(recordingTime)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
{#if !error}
|
||||
<div class="flex items-center justify-center gap-8 bg-black/80 px-4 py-6">
|
||||
<!-- Close -->
|
||||
<button
|
||||
onclick={onclose}
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-white/20 text-white"
|
||||
aria-label="Schliessen"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Capture photo / record video -->
|
||||
{#if recording}
|
||||
<button
|
||||
onclick={stopRecording}
|
||||
class="flex h-16 w-16 items-center justify-center rounded-full border-4 border-white bg-red-600"
|
||||
aria-label="Aufnahme stoppen"
|
||||
>
|
||||
<div class="h-6 w-6 rounded-sm bg-white"></div>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={capturePhoto}
|
||||
class="flex h-16 w-16 items-center justify-center rounded-full border-4 border-white bg-white/20 transition active:bg-white/40"
|
||||
aria-label="Foto aufnehmen"
|
||||
>
|
||||
<div class="h-12 w-12 rounded-full bg-white"></div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Toggle camera / start recording -->
|
||||
{#if recording}
|
||||
<div class="h-12 w-12"></div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={toggleCamera}
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-white/20 text-white"
|
||||
aria-label="Kamera wechseln"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
|
||||
<!-- Video record button -->
|
||||
{#if !recording}
|
||||
<div class="flex justify-center bg-black/80 pb-4">
|
||||
<button
|
||||
onclick={startRecording}
|
||||
class="flex items-center gap-2 rounded-full bg-red-600/80 px-4 py-2 text-sm text-white transition hover:bg-red-600"
|
||||
>
|
||||
<div class="h-2.5 w-2.5 rounded-full bg-white"></div>
|
||||
Video aufnehmen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hidden canvas for photo capture -->
|
||||
<canvas bind:this={canvasEl} class="hidden"></canvas>
|
||||
Reference in New Issue
Block a user