'use client'; import { useState, useRef, useEffect } from 'react'; const API_URL = process.env.NEXT_PUBLIC_API_URL || ''; const DB_NAME = 'bird-capture-queue'; const STORE_NAME = 'pending'; // IndexedDB queue for offline captures 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(blob: Blob, mimeType: string) { const db = await openDB(); const id = Date.now().toString(); 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.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); }); } async function submitCapture(blob: Blob, mimeType: string): Promise { try { const form = new FormData(); form.append('audio', blob, 'capture.webm'); const res = await fetch(`${API_URL}/api/capture`, { method: 'POST', body: form }); return res.ok; } catch { return false; } } export default function CapturePage() { const [state, setState] = useState<'idle' | 'recording' | 'transcribing' | 'saved' | 'queued' | 'error'>('idle'); const [countdown, setCountdown] = useState(0); const [recentCaptures, setRecentCaptures] = useState<{name: string, duration: string}[]>([]); const [pendingCount, setPendingCount] = useState(0); 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; useEffect(() => { loadRecentCaptures(); checkPendingQueue(); // Retry queue on focus (when user returns to app) 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 checkPendingQueue() { try { const pending = await getPendingCaptures(); setPendingCount(pending.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 remaining = await getPendingCaptures(); setPendingCount(remaining.length); if (remaining.length < pending.length) { await loadRecentCaptures(); } } catch {} } 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) 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); } }; mr.start(1000); mediaRecorderRef.current = mr; setState('recording'); setCountdown(MAX_SECONDS); await acquireWakeLock(); countdownRef.current = setInterval(() => { setCountdown(prev => { if (prev <= 1) { stopRecording(); return 0; } return prev - 1; }); }, 1000); } catch { setState('error'); setTimeout(() => setState('idle'), 3000); } } function stopRecording() { if (countdownRef.current) { clearInterval(countdownRef.current); countdownRef.current = null; } releaseWakeLock(); mediaRecorderRef.current?.stop(); } function handleTap() { if (state === 'recording') stopRecording(); else if (state === 'idle') startRecording(); } const isRecording = state === 'recording'; const isBusy = state === 'transcribing'; return (
{/* Header */}

Bird — field recorder

{pendingCount > 0 && (

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

)}
{/* Main button 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) => (
))}
{/* Big button */} {/* Status */}
{state === 'idle' && (

tap to capture

)} {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...

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

saved ✓

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

queued — will sync when online

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

error — try again

)}
{/* Recent captures */}

recent captures

{recentCaptures.length === 0 ? (

none yet

) : (
{recentCaptures.slice(0, 4).map((c, i) => (
{c.name}
))}
)}
); }