'use client'; import { nanoid } from 'nanoid'; import { useEffect, useRef, useState } from 'react'; import { Icon } from '../icons'; import { createMediaItem } from '../lib/media'; import { useGallery, useGalleryStoreApi } from '../store/context'; import type { Annotation, MediaItem } from '../types'; import { Annotations, type AnnotationTool } from './editor/Annotations'; type Mode = 'photo' | 'video'; type Facing = 'user' | 'environment'; const ANN_TOOLS: Array<{ tool: AnnotationTool; label: string; icon: 'check' | 'aspect' | 'chevron-right' | 'crop' | 'tag' | 'wand' }> = [ { tool: 'select', label: 'Off', icon: 'check' }, { tool: 'rect', label: 'Box', icon: 'aspect' }, { tool: 'arrow', label: 'Arrow', icon: 'chevron-right' }, { tool: 'double-arrow', label: 'Measure', icon: 'crop' }, { tool: 'text', label: 'Text', icon: 'tag' }, { tool: 'freehand', label: 'Draw', icon: 'wand' }, ]; const ANN_COLORS = ['#ff3b30', '#ffd60a', '#34c759', '#0a84ff', '#ffffff']; export function Camera() { const api = useGalleryStoreApi(); const open = useGallery((s) => s.cameraOpen); const videoRef = useRef(null); const streamRef = useRef(null); const recorderRef = useRef(null); const chunksRef = useRef([]); const recordStartRef = useRef(0); const locationRef = useRef(undefined); const deviceRef = useRef<{ label?: string; width?: number; height?: number }>({}); const [mode, setMode] = useState('photo'); const [facing, setFacing] = useState('environment'); const [grid, setGrid] = useState(false); const [annTool, setAnnTool] = useState('rect'); const [annColor, setAnnColor] = useState('#ff3b30'); const [annotations, setAnnotations] = useState([]); const [recording, setRecording] = useState(false); const [error, setError] = useState(null); const [review, setReview] = useState<{ url: string; blob: Blob; kind: 'image' | 'video'; w: number; h: number } | null>(null); // Start / restart the camera stream when open or facing changes. useEffect(() => { if (!open) return; let cancelled = false; setError(null); (async () => { try { if (!navigator.mediaDevices?.getUserMedia) throw new Error('Camera API not available in this browser.'); const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: facing, width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: true, }).catch(async () => navigator.mediaDevices.getUserMedia({ video: { facingMode: facing } }), ); if (cancelled) { stream.getTracks().forEach((t) => t.stop()); return; } streamRef.current = stream; const track = stream.getVideoTracks()[0]; const settings = track?.getSettings?.() ?? {}; deviceRef.current = { label: track?.label || 'Web Camera', width: settings.width, height: settings.height }; if (videoRef.current) { videoRef.current.srcObject = stream; await videoRef.current.play().catch(() => undefined); } } catch (e) { setError( e instanceof Error && e.name === 'NotAllowedError' ? 'Camera permission denied. Allow camera access and try again.' : 'Could not start the camera.', ); } })(); return () => { cancelled = true; stopStream(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, facing]); // Prefetch location when the camera opens so a fix is ready by capture time. useEffect(() => { if (!open) return; let cancelled = false; void getCurrentLocation().then((loc) => { if (!cancelled && loc) locationRef.current = loc; }); return () => { cancelled = true; }; }, [open]); // Reset transient state when closing. useEffect(() => { if (!open) { setReview((r) => { if (r) URL.revokeObjectURL(r.url); return null; }); setAnnotations([]); setRecording(false); } }, [open]); if (!open) return null; const stopStream = () => { streamRef.current?.getTracks().forEach((t) => t.stop()); streamRef.current = null; }; const close = () => { if (review) URL.revokeObjectURL(review.url); stopStream(); api.getState().closeCamera(); }; const capturePhoto = () => { const video = videoRef.current; if (!video || !video.videoWidth) return; const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); if (!ctx) return; // Mirror the front camera to match the on-screen preview. if (facing === 'user') { ctx.translate(canvas.width, 0); ctx.scale(-1, 1); } ctx.drawImage(video, 0, 0); canvas.toBlob( (blob) => { if (!blob) return; setReview({ url: URL.createObjectURL(blob), blob, kind: 'image', w: canvas.width, h: canvas.height }); }, 'image/jpeg', 0.92, ); }; const toggleRecord = () => { if (recording) { recorderRef.current?.stop(); return; } const stream = streamRef.current; if (!stream) return; chunksRef.current = []; const types = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4']; const mimeType = types.find((t) => typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(t)); const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); }; recorder.onstop = () => { const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'video/webm' }); const v = videoRef.current; setReview({ url: URL.createObjectURL(blob), blob, kind: 'video', w: v?.videoWidth ?? 1280, h: v?.videoHeight ?? 720, }); setRecording(false); }; recorder.start(); recorderRef.current = recorder; recordStartRef.current = Date.now(); setRecording(true); }; const usePhoto = async () => { if (!review) return; const now = Date.now(); const id = nanoid(10); const location = locationRef.current ?? (await getCurrentLocation()); const device = deviceRef.current; const exif: Record = { Make: 'Web Camera', Model: device.label || 'Web Camera', }; if (device.width && device.height) exif.Resolution = `${device.width}×${device.height}`; const duration = review.kind === 'video' ? Math.max(1, Math.round((Date.now() - recordStartRef.current) / 1000)) : undefined; // Prefer the backend (Supabase Storage) for a durable URL; otherwise fall back // to a data URL (durable across reloads for BOTH photos and video). Only if that // fails do we keep the in-session object URL as a last resort. const uploaded = await api.getState().uploadBlob(id, review.blob); const src = uploaded ?? (await blobToDataUrl(review.blob).catch(() => review.url)); const item: MediaItem = createMediaItem({ id, src, name: `${review.kind === 'video' ? 'VID' : 'IMG'}_${formatStamp(now)}.${review.kind === 'video' ? 'webm' : 'jpg'}`, kind: review.kind, mime: review.kind === 'video' ? (review.blob.type || 'video/webm') : 'image/jpeg', bytes: review.blob.size, width: review.w, height: review.h, takenAt: now, source: 'camera', duration, location, exif, // Stamp who captured this (display identity) when the host supplies a user. uploadedBy: api.getState().config.currentUser, edits: annotations.length ? { adjustments: {}, annotations } : undefined, }); api.getState().addMedia([item]); setReview(null); setAnnotations([]); close(); api.getState().setView('library'); // Auto object-detection (AIAnalyzer) will tag the new capture when an AI provider is configured. }; const retake = () => { if (review) URL.revokeObjectURL(review.url); setReview(null); setAnnotations([]); }; return (
{(['photo', 'video'] as Mode[]).map((m) => ( ))}
{error ? (

{error}

) : review ? (
{review.kind === 'image' ? (
Captured
) : (
) : (
{review?.kind === 'image' ? (
{ANN_TOOLS.map((t) => ( ))} {ANN_COLORS.map((c) => (
) : null}
{review ? ( <> ) : (
); } function getCurrentLocation(): Promise { return new Promise((resolve) => { if (typeof navigator === 'undefined' || !navigator.geolocation) return resolve(undefined); navigator.geolocation.getCurrentPosition( (pos) => resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude }), () => resolve(undefined), { enableHighAccuracy: true, timeout: 10_000, maximumAge: 300_000 }, ); }); } function blobToDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => reject(new Error('read failed')); reader.readAsDataURL(blob); }); } function formatStamp(ms: number): string { const d = new Date(ms); const p = (n: number) => n.toString().padStart(2, '0'); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; }