From e28d5555f145711155abbd694feedfd3dd3e5d26 Mon Sep 17 00:00:00 2001 From: Aaron Nelson Date: Sun, 26 Apr 2026 18:40:02 -0400 Subject: [PATCH] =?UTF-8?q?Add=20/capture=20page=20=E2=80=94=20field=20rec?= =?UTF-8?q?order=20interface,=20auth-free,=20voice=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/capture/page.tsx | 281 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 app/capture/page.tsx diff --git a/app/capture/page.tsx b/app/capture/page.tsx new file mode 100644 index 0000000..868dab8 --- /dev/null +++ b/app/capture/page.tsx @@ -0,0 +1,281 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || ''; + +export default function CapturePage() { + const [state, setState] = useState<'idle' | 'recording' | 'transcribing' | 'saved' | 'error'>('idle'); + const [countdown, setCountdown] = useState(0); + const [recentCaptures, setRecentCaptures] = useState<{name: string, duration: 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 = 60; + + useEffect(() => { + loadRecentCaptures(); + }, []); + + 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 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 () => { + if (chunksRef.current.length === 0) return; + setState('transcribing'); + try { + const blob = new Blob(chunksRef.current, { type: mimeTypeRef.current }); + const form = new FormData(); + form.append('file', 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'); + setTimeout(() => setState('idle'), 3000); + } + }; + 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 +

+
+ + {/* Main button area */} +
+ + {/* Waveform — decorative */} +
+ {[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 +

+ {countdown <= 15 && ( +

+ {countdown}s +

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

+ transcribing... +

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

+ saved ✓ +

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

+ error — try again +

+ )} +
+
+ + {/* Recent captures */} +
+

+ recent captures +

+ {recentCaptures.length === 0 ? ( +

none yet

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