diff --git a/app/capture/page.tsx b/app/capture/page.tsx index 9069ee0..08ab2b7 100644 --- a/app/capture/page.tsx +++ b/app/capture/page.tsx @@ -1,12 +1,13 @@ 'use client'; -import { useState, useRef, useEffect } from 'react'; +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 for offline captures +// IndexedDB queue async function openDB(): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); @@ -16,12 +17,19 @@ async function openDB(): Promise { }); } -async function queueCapture(blob: Blob, mimeType: string) { +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, blob, mimeType, timestamp: new Date().toISOString() }); + tx.objectStore(STORE_NAME).put({ + id, + audio: audio || null, + image: image || null, + timestamp: new Date().toISOString(), + }); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); @@ -47,10 +55,36 @@ async function removeCapture(id: string) { }); } -async function submitCapture(blob: Blob, mimeType: string): Promise { +// 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(); - form.append('audio', blob, 'capture.webm'); + 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 { @@ -58,23 +92,28 @@ async function submitCapture(blob: Blob, mimeType: string): Promise { } } +type CaptureMode = 'idle' | 'image-selected' | 'recording' | 'submitting' | 'saved' | 'queued' | 'error'; + export default function CapturePage() { - const [state, setState] = useState<'idle' | 'recording' | 'transcribing' | 'saved' | 'queued' | 'error'>('idle'); + const [mode, setMode] = useState('idle'); + const [imagePreview, setImagePreview] = useState(null); + const [imageBlob, setImageBlob] = useState(null); const [countdown, setCountdown] = useState(0); - const [recentCaptures, setRecentCaptures] = useState<{name: string, duration: string}[]>([]); 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 MAX_SECONDS = 600; + const fileInputRef = useRef(null); + const MAX_SECONDS = 120; // shorter for annotation — 2 min max useEffect(() => { loadRecentCaptures(); - checkPendingQueue(); - // Retry queue on focus (when user returns to app) + checkPending(); window.addEventListener('focus', retryQueue); window.addEventListener('online', retryQueue); return () => { @@ -93,31 +132,54 @@ export default function CapturePage() { } catch {} } - async function checkPendingQueue() { + async function checkPending() { try { - const pending = await getPendingCaptures(); - setPendingCount(pending.length); + const p = await getPendingCaptures(); + setPendingCount(p.length); } catch {} } async function retryQueue() { try { const pending = await getPendingCaptures(); - if (pending.length === 0) return; for (const item of pending) { - const ok = await submitCapture(item.blob, item.mimeType); - if (ok) { - await removeCapture(item.id); - } + 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) { - await loadRecentCaptures(); - } + 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) { @@ -145,29 +207,17 @@ export default function CapturePage() { mr.onstop = async () => { streamRef.current?.getTracks().forEach(t => t.stop()); streamRef.current = null; - if (chunksRef.current.length === 0) return; - setState('transcribing'); - const blob = new Blob(chunksRef.current, { type: mimeTypeRef.current }); - const ok = await submitCapture(blob, mimeTypeRef.current); - if (ok) { - setState('saved'); - await loadRecentCaptures(); - setTimeout(() => setState('idle'), 3000); - } else { - // Queue for later — don't lose the capture - try { - await queueCapture(blob, mimeTypeRef.current); - await checkPendingQueue(); - setState('queued'); - } catch { - setState('error'); - } - setTimeout(() => setState('idle'), 4000); + 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; - setState('recording'); + setMode('recording'); setCountdown(MAX_SECONDS); await acquireWakeLock(); countdownRef.current = setInterval(() => { @@ -177,24 +227,64 @@ export default function CapturePage() { }); }, 1000); } catch { - setState('error'); - setTimeout(() => setState('idle'), 3000); + setMode('error'); + setTimeout(() => setMode(imageBlob ? 'image-selected' : 'idle'), 3000); } } function stopRecording() { if (countdownRef.current) { clearInterval(countdownRef.current); countdownRef.current = null; } releaseWakeLock(); - mediaRecorderRef.current?.stop(); + if (mediaRecorderRef.current?.state === 'recording') { + mediaRecorderRef.current.stop(); + } } - function handleTap() { - if (state === 'recording') stopRecording(); - else if (state === 'idle') startRecording(); + // ── 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); + } } - const isRecording = state === 'recording'; - const isBusy = state === 'transcribing'; + 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 (
{pendingCount > 0 && (

- {pendingCount} capture{pendingCount > 1 ? 's' : ''} queued — will sync when online + {pendingCount} capture{pendingCount > 1 ? 's' : ''} queued

)}
- {/* Main button area */} -
+ {/* Main area */} +
- {/* Waveform */} -
- {[4,8,16,6,24,10,32,14,20,8,12,28,6,18,4,22,10,16,8,4].map((h, i) => ( -
- ))} + {/* 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 ? ( + + ) : ( +
+ )}
- {/* Big button */} - + {/* Recording progress */} + {isRecording && ( +
+
+
+
+

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

+
+ )} {/* Status */} -
- {state === 'idle' && ( -

- tap to capture +

+ {mode === 'idle' && ( +

+ tap camera or mic

)} - {state === 'recording' && ( - <> -

- recording -

-
-
-
-

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

- - )} - {state === 'transcribing' && ( -

- transcribing... + {mode === 'image-selected' && ( +

+ annotate with voice or save now

)} - {state === 'saved' && ( -

+ {mode === 'recording' && ( +

+ recording annotation +

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

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

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

saved ✓

)} - {state === 'queued' && ( -

+ {mode === 'queued' && ( +

queued — will sync when online

)} - {state === 'error' && ( -

+ {mode === 'error' && ( +

error — try again

)} @@ -330,19 +500,22 @@ export default function CapturePage() { {/* Recent captures */}
-

+

recent captures

{recentCaptures.length === 0 ? (

none yet

) : ( -
+
{recentCaptures.slice(0, 4).map((c, i) => (
-
+
{c.name} @@ -352,6 +525,16 @@ export default function CapturePage() { )}
+ {/* Hidden file input */} + +