diff --git a/app/capture/page.tsx b/app/capture/page.tsx index 70b070d..9069ee0 100644 --- a/app/capture/page.tsx +++ b/app/capture/page.tsx @@ -3,11 +3,66 @@ 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' | 'error'>('idle'); + 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); @@ -18,6 +73,14 @@ export default function CapturePage() { 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() { @@ -30,6 +93,31 @@ export default function CapturePage() { } 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) { @@ -55,28 +143,26 @@ export default function CapturePage() { const mr = new MediaRecorder(streamRef.current, { mimeType }); mr.ondataavailable = e => { if (e.data.size > 0) chunksRef.current.push(e.data); }; mr.onstop = async () => { - // Release microphone immediately after stopping streamRef.current?.getTracks().forEach(t => t.stop()); streamRef.current = null; - if (chunksRef.current.length === 0) return; setState('transcribing'); - try { - const blob = new Blob(chunksRef.current, { type: mimeTypeRef.current }); - const form = new FormData(); - form.append('audio', blob, 'capture.webm'); - const res = await fetch(`${API_URL}/api/capture`, { method: 'POST', body: form }); - if (res.ok) { - setState('saved'); - await loadRecentCaptures(); - setTimeout(() => setState('idle'), 3000); - } else { - setState('error'); - setTimeout(() => setState('idle'), 3000); - } - } catch { - setState('error'); + 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); @@ -127,35 +213,27 @@ export default function CapturePage() { {/* Header */}
-

+

Bird — field recorder

+ {pendingCount > 0 && ( +

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

+ )}
{/* Main button area */}
- {/* Waveform — decorative */} + {/* Waveform */}
{[4,8,16,6,24,10,32,14,20,8,12,28,6,18,4,22,10,16,8,4].map((h, i) => (
))} @@ -166,17 +244,12 @@ export default function CapturePage() { onPointerUp={handleTap} disabled={isBusy} style={{ - width: '160px', - height: '160px', - borderRadius: '50%', + width: '160px', height: '160px', borderRadius: '50%', border: `1.5px solid ${isRecording ? '#a32d2d' : 'var(--accent)'}`, background: isRecording ? 'rgba(163,45,45,0.08)' : 'var(--bg2)', cursor: isBusy ? 'not-allowed' : 'pointer', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - transition: 'all 0.3s ease', - touchAction: 'manipulation', + display: 'flex', alignItems: 'center', justifyContent: 'center', + transition: 'all 0.3s ease', touchAction: 'manipulation', animation: isBusy ? 'none' : isRecording ? 'recordPulse 1.5s ease-in-out infinite' : 'idlePulse 3s ease-in-out infinite', }} aria-label={isRecording ? 'Stop recording' : 'Start recording'} @@ -220,7 +293,7 @@ export default function CapturePage() { transition: 'width 1s linear, background 0.5s ease', }} />
-

+

{(() => { const elapsed = MAX_SECONDS - countdown; const em = Math.floor(elapsed / 60); @@ -242,6 +315,11 @@ export default function CapturePage() { saved ✓

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

+ queued — will sync when online +

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

error — try again @@ -261,19 +339,13 @@ export default function CapturePage() {

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