'use client'; import { useState, useRef, useEffect, useCallback } from 'react'; const API_URL = process.env.NEXT_PUBLIC_API_URL || ''; const DB_NAME = 'bird-capture-queue'; const STORE_NAME = 'pending'; const MAX_IMAGE_PX = 1568; // Claude vision optimal max dimension // IndexedDB queue async function openDB(): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME, { keyPath: 'id' }); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function queueCapture(formData: FormData) { const db = await openDB(); const id = Date.now().toString(); const audio = formData.get('audio') as Blob | null; const image = formData.get('image') as Blob | null; return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readwrite'); tx.objectStore(STORE_NAME).put({ id, audio: audio || null, image: image || null, timestamp: new Date().toISOString(), }); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } async function getPendingCaptures(): Promise { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readonly'); const req = tx.objectStore(STORE_NAME).getAll(); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function removeCapture(id: string) { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readwrite'); tx.objectStore(STORE_NAME).delete(id); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } // Resize image client-side before upload async function resizeImage(file: File): Promise { return new Promise((resolve) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(url); const { width, height } = img; const maxDim = MAX_IMAGE_PX; let w = width, h = height; if (w > maxDim || h > maxDim) { if (w > h) { h = Math.round(h * maxDim / w); w = maxDim; } else { w = Math.round(w * maxDim / h); h = maxDim; } } const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d')!; ctx.drawImage(img, 0, 0, w, h); canvas.toBlob((blob) => resolve(blob!), 'image/jpeg', 0.88); }; img.src = url; }); } async function submitCapture(audio: Blob | null, image: Blob | null): Promise { try { const form = new FormData(); if (audio) form.append('audio', audio, 'capture.webm'); if (image) form.append('image', image, 'capture.jpg'); const res = await fetch(`${API_URL}/api/capture`, { method: 'POST', body: form }); return res.ok; } catch { return false; } } type CaptureMode = 'idle' | 'image-selected' | 'recording' | 'submitting' | 'saved' | 'queued' | 'error'; export default function CapturePage() { const [mode, setMode] = useState('idle'); const [imagePreview, setImagePreview] = useState(null); const [imageBlob, setImageBlob] = useState(null); const [countdown, setCountdown] = useState(0); const [pendingCount, setPendingCount] = useState(0); const [recentCaptures, setRecentCaptures] = useState<{name: string}[]>([]); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); const streamRef = useRef(null); const wakeLockRef = useRef(null); const countdownRef = useRef(null); const mimeTypeRef = useRef('audio/webm'); const fileInputRef = useRef(null); const MAX_SECONDS = 120; // shorter for annotation — 2 min max useEffect(() => { loadRecentCaptures(); checkPending(); window.addEventListener('focus', retryQueue); window.addEventListener('online', retryQueue); return () => { window.removeEventListener('focus', retryQueue); window.removeEventListener('online', retryQueue); }; }, []); async function loadRecentCaptures() { try { const res = await fetch(`${API_URL}/api/captures`); if (res.ok) { const data = await res.json(); setRecentCaptures(data.captures || []); } } catch {} } async function checkPending() { try { const p = await getPendingCaptures(); setPendingCount(p.length); } catch {} } async function retryQueue() { try { const pending = await getPendingCaptures(); for (const item of pending) { const ok = await submitCapture(item.audio, item.image); if (ok) await removeCapture(item.id); } const remaining = await getPendingCaptures(); setPendingCount(remaining.length); if (remaining.length < pending.length) loadRecentCaptures(); } catch {} } // ── Image selection ──────────────────────────────────────────────────────── function handleCameraClick() { fileInputRef.current?.click(); } async function handleImageSelected(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; const resized = await resizeImage(file); setImageBlob(resized); const preview = URL.createObjectURL(resized); setImagePreview(preview); setMode('image-selected'); // Reset input so same file can be reselected e.target.value = ''; } function clearImage() { if (imagePreview) URL.revokeObjectURL(imagePreview); setImagePreview(null); setImageBlob(null); setMode('idle'); stopRecording(); } // ── Voice recording ──────────────────────────────────────────────────────── async function acquireWakeLock() { try { if ('wakeLock' in navigator) { wakeLockRef.current = await (navigator as any).wakeLock.request('screen'); } } catch {} } function releaseWakeLock() { wakeLockRef.current?.release(); wakeLockRef.current = null; } async function startRecording() { try { if (!streamRef.current || streamRef.current.getTracks().every(t => t.readyState === 'ended')) { streamRef.current = await navigator.mediaDevices.getUserMedia({ audio: true }); } const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : MediaRecorder.isTypeSupported('audio/mp4') ? 'audio/mp4' : 'audio/ogg'; mimeTypeRef.current = mimeType; chunksRef.current = []; const mr = new MediaRecorder(streamRef.current, { mimeType }); mr.ondataavailable = e => { if (e.data.size > 0) chunksRef.current.push(e.data); }; mr.onstop = async () => { streamRef.current?.getTracks().forEach(t => t.stop()); streamRef.current = null; if (chunksRef.current.length === 0) { // No audio recorded — submit image only await submitFinal(null); return; } const audioBlob = new Blob(chunksRef.current, { type: mimeTypeRef.current }); await submitFinal(audioBlob); }; mr.start(1000); mediaRecorderRef.current = mr; setMode('recording'); setCountdown(MAX_SECONDS); await acquireWakeLock(); countdownRef.current = setInterval(() => { setCountdown(prev => { if (prev <= 1) { stopRecording(); return 0; } return prev - 1; }); }, 1000); } catch { setMode('error'); setTimeout(() => setMode(imageBlob ? 'image-selected' : 'idle'), 3000); } } function stopRecording() { if (countdownRef.current) { clearInterval(countdownRef.current); countdownRef.current = null; } releaseWakeLock(); if (mediaRecorderRef.current?.state === 'recording') { mediaRecorderRef.current.stop(); } } // ── Submission ───────────────────────────────────────────────────────────── async function submitFinal(audioBlob: Blob | null) { setMode('submitting'); const ok = await submitCapture(audioBlob, imageBlob); if (ok) { setMode('saved'); if (imagePreview) URL.revokeObjectURL(imagePreview); setImagePreview(null); setImageBlob(null); await loadRecentCaptures(); setTimeout(() => setMode('idle'), 3000); } else { try { const form = new FormData(); if (audioBlob) form.append('audio', audioBlob, 'capture.webm'); if (imageBlob) form.append('image', imageBlob, 'capture.jpg'); await queueCapture(form); await checkPending(); setMode('queued'); } catch { setMode('error'); } if (imagePreview) URL.revokeObjectURL(imagePreview); setImagePreview(null); setImageBlob(null); setTimeout(() => setMode('idle'), 4000); } } async function submitImageOnly() { await submitFinal(null); } // ── Tap handler for voice button ─────────────────────────────────────────── function handleVoiceTap() { if (mode === 'recording') stopRecording(); else if (mode === 'image-selected') startRecording(); else if (mode === 'idle') startRecording(); // voice-only capture } const isRecording = mode === 'recording'; const isBusy = mode === 'submitting'; const hasImage = imageBlob !== null; return (
{/* Header */}

Bird — field recorder

{pendingCount > 0 && (

{pendingCount} capture{pendingCount > 1 ? 's' : ''} queued

)}
{/* Main area */}
{/* Image preview */} {imagePreview && (
capture preview
)} {/* Waveform — shows during recording */} {isRecording && (
{[4,8,16,6,24,10,32,14,20,8,12,28,6,18,4].map((h, i) => (
))}
)} {/* Capture buttons row */}
{/* Camera button — always visible, opens image picker */} {/* Mic / submit button — large center */} {/* Submit image-only button — visible only when image selected and not recording */} {hasImage && !isRecording && !isBusy ? ( ) : (
)}
{/* Recording progress */} {isRecording && (

{(() => { const rm = Math.floor(countdown / 60); const rs = countdown % 60; return `${rm}:${rs.toString().padStart(2,'0')} left`; })()}

)} {/* Status */}
{mode === 'idle' && (

tap camera or mic

)} {mode === 'image-selected' && (

annotate with voice or save now

)} {mode === 'recording' && (

recording annotation

)} {mode === 'submitting' && (

{hasImage ? 'processing image...' : 'transcribing...'}

)} {mode === 'saved' && (

saved ✓

)} {mode === 'queued' && (

queued — will sync when online

)} {mode === 'error' && (

error — try again

)}
{/* Recent captures */}

recent captures

{recentCaptures.length === 0 ? (

none yet

) : (
{recentCaptures.slice(0, 4).map((c, i) => (
{c.name}
))}
)}
{/* Hidden file input */}
); }