Files
aaronai-web/app/capture/page.tsx
T

366 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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';
const MAX_IMAGE_PX = 1568;
async function openDB(): Promise<IDBDatabase> {
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(audio: Blob | null, image: Blob | null) {
const db = await openDB();
const id = Date.now().toString();
return new Promise<void>((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<any[]> {
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<void>((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 resizeImage(file: File): Promise<Blob> {
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<{ ok: boolean; errorType?: string }> {
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 });
if (res.ok) return { ok: true };
const body = await res.json().catch(() => ({}));
return { ok: false, errorType: body.error_type };
} catch {
return { ok: false };
}
}
type CaptureMode = 'idle' | 'image-selected' | 'recording' | 'submitting' | 'saved' | 'queued' | 'error';
export default function CapturePage() {
const [mode, setMode] = useState<CaptureMode>('idle');
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [imageBlob, setImageBlob] = useState<Blob | null>(null);
const [countdown, setCountdown] = useState(0);
const [pendingCount, setPendingCount] = useState(0);
const [recentCaptures, setRecentCaptures] = useState<{name: string}[]>([]);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const wakeLockRef = useRef<any>(null);
const countdownRef = useRef<any>(null);
const mimeTypeRef = useRef('audio/webm');
const fileInputRef = useRef<HTMLInputElement>(null);
const imageBlobRef = useRef<Blob | null>(null);
const MAX_SECONDS = 120;
useEffect(() => { imageBlobRef.current = imageBlob; }, [imageBlob]);
useEffect(() => {
loadRecentCaptures();
checkPending();
window.addEventListener('focus', retryQueue);
window.addEventListener('online', retryQueue);
// Listen for capture_saved SSE — refresh list when transcription completes
let es: EventSource | null = null;
function connectSSE() {
es = new EventSource(`${API_URL}/api/captures/events`);
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.type === 'capture_saved') loadRecentCaptures();
} catch {}
};
es.onerror = () => { es?.close(); setTimeout(connectSSE, 10000); };
}
connectSSE();
return () => {
window.removeEventListener('focus', retryQueue);
window.removeEventListener('online', retryQueue);
es?.close();
};
}, []);
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 result = await submitCapture(item.audio, item.image);
if (result.ok) await removeCapture(item.id);
}
const remaining = await getPendingCaptures();
setPendingCount(remaining.length);
if (remaining.length < pending.length) loadRecentCaptures();
} catch {}
}
function handleCameraClick() { fileInputRef.current?.click(); }
async function handleImageSelected(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const resized = await resizeImage(file);
setImageBlob(resized);
imageBlobRef.current = resized;
setImagePreview(URL.createObjectURL(resized));
setMode('image-selected');
e.target.value = '';
}
function clearImage() {
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImagePreview(null);
setImageBlob(null);
imageBlobRef.current = null;
setMode('idle');
stopRecording();
}
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;
const audioBlob = chunksRef.current.length > 0 ? new Blob(chunksRef.current, { type: mimeTypeRef.current }) : null;
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(imageBlobRef.current ? 'image-selected' : 'idle'), 3000);
}
}
function stopRecording() {
if (countdownRef.current) { clearInterval(countdownRef.current); countdownRef.current = null; }
releaseWakeLock();
if (mediaRecorderRef.current?.state === 'recording') mediaRecorderRef.current.stop();
}
async function submitFinal(audioBlob: Blob | null, retryCount = 0) {
setMode('submitting');
const currentImageBlob = imageBlobRef.current;
const result = await submitCapture(audioBlob, currentImageBlob);
if (result.ok) {
setMode('saved');
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImagePreview(null);
setImageBlob(null);
imageBlobRef.current = null;
// Optimistic update — show immediately as processing
setRecentCaptures(prev => [{ name: `${new Date().toISOString().slice(0,16).replace('T','-').replace(':','-')}-voice ⏳` }, ...prev].slice(0, 4));
setTimeout(() => setMode('idle'), 3000);
return;
}
const isTranscriptionFailure = result.errorType === 'transcription_failed' || result.errorType === 'empty_transcript';
if (isTranscriptionFailure && retryCount < 2) {
setTimeout(() => submitFinal(audioBlob, retryCount + 1), 3000 * (retryCount + 1));
return;
}
try {
await queueCapture(audioBlob, currentImageBlob);
await checkPending();
setMode('queued');
} catch {
setMode('error');
}
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImagePreview(null);
setImageBlob(null);
imageBlobRef.current = null;
setTimeout(() => setMode('idle'), 4000);
}
async function submitImageOnly() { await submitFinal(null); }
function handleVoiceTap() {
if (mode === 'recording') stopRecording();
else if (mode === 'image-selected' || mode === 'idle') startRecording();
}
const isRecording = mode === 'recording';
const isBusy = mode === 'submitting';
const hasImage = imageBlob !== null;
return (
<div style={{ minHeight: '100dvh', background: 'var(--bg)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'space-between', paddingTop: 'max(48px, env(safe-area-inset-top))', paddingBottom: 'max(32px, env(safe-area-inset-bottom))', paddingLeft: 'max(24px, env(safe-area-inset-left))', paddingRight: 'max(24px, env(safe-area-inset-right))', fontFamily: 'var(--font-mono)' }}>
<div style={{ textAlign: 'center' }}>
<p style={{ fontSize: '10px', letterSpacing: '0.15em', color: 'var(--text3)', textTransform: 'uppercase', margin: 0 }}>Bird field recorder</p>
{pendingCount > 0 && <p style={{ fontSize: '9px', color: '#c8821a', letterSpacing: '0.1em', margin: '4px 0 0', textTransform: 'uppercase' }}>{pendingCount} capture{pendingCount > 1 ? 's' : ''} queued</p>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '28px', width: '100%', maxWidth: '320px' }}>
{imagePreview && (
<div style={{ position: 'relative', width: '100%' }}>
<img src={imagePreview} alt="capture preview" style={{ width: '100%', maxHeight: '240px', objectFit: 'cover', borderRadius: '12px', border: '1px solid var(--border)', display: 'block' }} />
<button onClick={clearImage} style={{ position: 'absolute', top: '8px', right: '8px', background: 'rgba(0,0,0,0.6)', border: 'none', borderRadius: '50%', width: '28px', height: '28px', color: '#fff', cursor: 'pointer', fontSize: '16px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>×</button>
</div>
)}
{isRecording && (
<div style={{ display: 'flex', alignItems: 'center', gap: '3px', height: '32px' }}>
{[4,8,16,6,24,10,32,14,20,8,12,28,6,18,4].map((h, i) => (
<div key={i} style={{ width: '2px', height: `${h}px`, background: 'var(--accent)', borderRadius: '1px', animation: `wave ${0.8 + (i % 4) * 0.2}s ease-in-out infinite alternate` }} />
))}
</div>
)}
<div style={{ display: 'flex', gap: '24px', alignItems: 'center', justifyContent: 'center' }}>
<button onClick={handleCameraClick} disabled={isBusy || isRecording} style={{ width: '72px', height: '72px', borderRadius: '50%', border: `1.5px solid ${hasImage ? 'var(--accent)' : 'var(--border2)'}`, background: hasImage ? 'var(--accent-light)' : 'var(--bg2)', cursor: isBusy || isRecording ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'all 0.2s', touchAction: 'manipulation', opacity: isBusy || isRecording ? 0.5 : 1 }} aria-label="Take photo or choose image">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke={hasImage ? 'var(--accent)' : 'var(--text3)'} strokeWidth="1.5"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
</button>
<button onPointerUp={handleVoiceTap} disabled={isBusy} style={{ width: '120px', height: '120px', 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', touchAction: 'manipulation', animation: isBusy ? 'none' : isRecording ? 'recordPulse 1.5s ease-in-out infinite' : 'idlePulse 3s ease-in-out infinite' }} aria-label={isRecording ? 'Stop recording' : 'Record voice annotation'}>
{isBusy ? (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--accent)' }}><circle cx="12" cy="12" r="3" opacity="0.6"><animate attributeName="opacity" values="0.6;1;0.6" dur="1s" repeatCount="indefinite"/></circle></svg>
) : isRecording ? (
<div style={{ width: '20px', height: '20px', background: '#a32d2d', borderRadius: '4px' }} />
) : (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5"><path d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4z"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg>
)}
</button>
{hasImage && !isRecording && !isBusy ? (
<button onClick={submitImageOnly} style={{ width: '72px', height: '72px', borderRadius: '50%', border: '1.5px solid var(--accent)', background: 'var(--accent-light)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'all 0.2s', touchAction: 'manipulation' }} aria-label="Save image without annotation">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2"><polyline points="20 6 9 17 4 12"/></svg>
</button>
) : <div style={{ width: '72px', height: '72px' }} />}
</div>
{isRecording && (
<div style={{ width: '100%' }}>
<div style={{ width: '100%', height: '2px', background: 'var(--border)', borderRadius: '1px', overflow: 'hidden', marginBottom: '6px' }}>
<div style={{ height: '100%', width: `${((MAX_SECONDS - countdown) / MAX_SECONDS) * 100}%`, background: countdown <= 15 ? '#a32d2d' : 'var(--accent)', borderRadius: '1px', transition: 'width 1s linear' }} />
</div>
<p style={{ fontSize: '10px', color: 'var(--text3)', margin: 0, textAlign: 'center' }}>{Math.floor(countdown/60)}:{(countdown%60).toString().padStart(2,'0')} left</p>
</div>
)}
<div style={{ textAlign: 'center', minHeight: '20px' }}>
{mode === 'idle' && <p style={{ fontSize: '10px', color: 'var(--text3)', letterSpacing: '0.12em', textTransform: 'uppercase', margin: 0 }}>tap camera or mic</p>}
{mode === 'image-selected' && <p style={{ fontSize: '10px', color: 'var(--text3)', letterSpacing: '0.10em', textTransform: 'uppercase', margin: 0 }}>annotate with voice or save now</p>}
{mode === 'recording' && <p style={{ fontSize: '10px', color: '#a32d2d', letterSpacing: '0.12em', textTransform: 'uppercase', margin: 0 }}>recording annotation</p>}
{mode === 'submitting' && <p style={{ fontSize: '10px', color: 'var(--text3)', letterSpacing: '0.12em', textTransform: 'uppercase', margin: 0 }}>transcribing...</p>}
{mode === 'saved' && <p style={{ fontSize: '10px', color: 'var(--accent)', letterSpacing: '0.12em', textTransform: 'uppercase', margin: 0 }}>saved </p>}
{mode === 'queued' && <p style={{ fontSize: '10px', color: '#c8821a', letterSpacing: '0.12em', textTransform: 'uppercase', margin: 0 }}>queued will sync when online</p>}
{mode === 'error' && <p style={{ fontSize: '10px', color: '#a32d2d', letterSpacing: '0.12em', textTransform: 'uppercase', margin: 0 }}>error try again</p>}
</div>
</div>
<div style={{ width: '100%', maxWidth: '320px' }}>
<p style={{ fontSize: '9px', color: 'var(--text3)', letterSpacing: '0.12em', textTransform: 'uppercase', margin: '0 0 8px' }}>recent captures</p>
{recentCaptures.length === 0 ? (
<p style={{ fontSize: '10px', color: 'var(--text3)', margin: 0, opacity: 0.5 }}>none yet</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
{recentCaptures.slice(0, 4).map((c, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 10px', background: 'var(--bg2)', borderRadius: '6px', border: '1px solid var(--border)' }}>
<div style={{ width: '6px', height: '6px', borderRadius: '50%', flexShrink: 0, background: c.name.includes('image') ? '#c8821a' : 'var(--accent)' }} />
<span style={{ fontSize: '10px', color: 'var(--text2)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</span>
</div>
))}
</div>
)}
</div>
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleImageSelected} style={{ display: 'none' }} />
<style>{`
@keyframes idlePulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(45,158,107,0); } 50% { box-shadow: 0 0 0 12px rgba(45,158,107,0.06); } }
@keyframes recordPulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(163,45,45,0); } 50% { box-shadow: 0 0 0 16px rgba(163,45,45,0.08); } }
@keyframes wave { from { transform: scaleY(0.4); } to { transform: scaleY(1.4); } }
`}</style>
</div>
);
}