diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 5a37d8d..b9e47d5 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -15,6 +15,7 @@ export default function RootLayout() { + diff --git a/src/app/index.tsx b/src/app/index.tsx index 8d64eb4..fbe3c25 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -90,15 +90,27 @@ export default function LibraryScreen() { - [ - styles.newButton, - { backgroundColor: '#3c87f7', opacity: busy ? 0.5 : pressed ? 0.85 : 1 }, - ]}> - + New transcription - + + + [ + styles.captureBtn, + { backgroundColor: '#e5484d', opacity: busy ? 0.5 : pressed ? 0.85 : 1 }, + ]}> + 🎙 Record + + + [ + styles.captureBtn, + { backgroundColor: '#3c87f7', opacity: busy ? 0.5 : pressed ? 0.85 : 1 }, + ]}> + + Import file + + {busy && } {job.status === 'error' && ( @@ -241,6 +253,8 @@ const styles = StyleSheet.create({ headerLinks: { flexDirection: 'row', gap: Spacing.three, alignItems: 'center' }, newButton: { paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' }, newButtonText: { color: '#fff', fontWeight: '700', fontSize: 16 }, + captureRow: { flexDirection: 'row', gap: Spacing.two }, + captureBtn: { flex: 1, paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' }, card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two }, rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two }, filterBar: { gap: Spacing.two, paddingVertical: Spacing.one, paddingRight: Spacing.three }, diff --git a/src/app/record.tsx b/src/app/record.tsx new file mode 100644 index 0000000..807c5e0 --- /dev/null +++ b/src/app/record.tsx @@ -0,0 +1,143 @@ +import { Stack, useRouter } from 'expo-router'; +import { useState } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { PreCaptureSheet, type PreCaptureResult } from '@/components/PreCaptureSheet'; +import { ThemedText } from '@/components/themed-text'; +import { ThemedView } from '@/components/themed-view'; +import { MaxContentWidth, Spacing } from '@/constants/theme'; +import { useTheme } from '@/hooks/use-theme'; +import { useRecorder, type RecordingResult } from '@/hooks/useRecorder'; +import { formatClock } from '@/lib/format'; +import { useTranscribe } from '@/stores/transcribeStore'; + +export default function RecordScreen() { + const theme = useTheme(); + const router = useRouter(); + const rec = useRecorder(); + const [pending, setPending] = useState(null); + + const onStop = async () => { + const r = await rec.stop(); + if (r) setPending(r); + else router.back(); + }; + + const onStart = async (meta: PreCaptureResult) => { + const r = pending; + setPending(null); + if (!r) return; + const id = await useTranscribe.getState().start({ data: r.data, name: r.name }, meta); + if (id) router.replace({ pathname: '/transcript/[id]', params: { id } }); + else router.back(); + }; + + if (!rec.supported) { + return ( + + + + In-app recording is available in the browser for now. On mobile, use your phone's + recorder and import the file (native live capture is coming). + + + ); + } + + const recording = rec.status === 'recording'; + const paused = rec.status === 'paused'; + + return ( + + + + {formatClock(Math.floor(rec.elapsedMs / 1000))} + + {recording ? '● Recording — on your device' : paused ? 'Paused' : 'Ready to record'} + + + + + + + {rec.error && ( + {rec.error} + )} + + + {rec.status === 'idle' || rec.status === 'stopped' ? ( + void rec.start()} /> + ) : ( + <> + {recording ? ( + + ) : ( + + )} + void onStop()} /> + + )} + + + + Keep this tab in front while recording. Everything stays on your device. + + + + void onStart(m)} + onCancel={() => { + setPending(null); + router.back(); + }} + /> + + ); +} + +function BigButton({ + label, + color, + textColor, + onPress, +}: { + label: string; + color: string; + textColor?: string; + onPress: () => void; +}) { + return ( + [styles.btn, { backgroundColor: color, opacity: pressed ? 0.85 : 1 }]}> + {label} + + ); +} + +function defaultName(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return `Lecture ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + center: { alignItems: 'center', justifyContent: 'center' }, + stage: { width: '100%', maxWidth: MaxContentWidth, padding: Spacing.four, gap: Spacing.three, alignItems: 'center' }, + timer: { fontSize: 56, fontWeight: '700', fontVariant: ['tabular-nums'] }, + meterTrack: { width: '80%', height: 8, borderRadius: 4, overflow: 'hidden' }, + meterFill: { height: 8, borderRadius: 4 }, + controls: { flexDirection: 'row', gap: Spacing.two, marginTop: Spacing.three, flexWrap: 'wrap', justifyContent: 'center' }, + btn: { paddingVertical: Spacing.three, paddingHorizontal: Spacing.four, borderRadius: Spacing.three, alignItems: 'center' }, + btnText: { fontWeight: '700', fontSize: 16 }, + btnTextLight: { color: '#fff' }, + error: { color: '#e5484d' }, + notice: { padding: Spacing.four, textAlign: 'center', maxWidth: 480 }, + hint: { textAlign: 'center', marginTop: Spacing.two }, +}); diff --git a/src/hooks/useRecorder.ts b/src/hooks/useRecorder.ts new file mode 100644 index 0000000..0818ba5 --- /dev/null +++ b/src/hooks/useRecorder.ts @@ -0,0 +1,185 @@ +// Web microphone recorder (MediaRecorder) for in-app lecture capture (ROADMAP +// Phase 2). Records to a Blob, exposes elapsed time + a live input level, holds +// a screen Wake Lock while recording, and supports pause/resume. On stop it +// returns the recording as an ArrayBuffer that flows straight into the existing +// transcribe pipeline (decode -> chunk -> Whisper -> save). +// +// Web-only: getUserMedia/MediaRecorder/AudioContext are browser APIs. On native +// `supported` is false and the controls no-op (native realtime capture via +// whisper.rn is a follow-up). +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Platform } from 'react-native'; + +export type RecorderStatus = 'idle' | 'recording' | 'paused' | 'stopped'; + +export interface RecordingResult { + data: ArrayBuffer; + mime: string; + name: string; +} + +export interface Recorder { + supported: boolean; + status: RecorderStatus; + elapsedMs: number; + /** Live input level in [0,1], for a meter. */ + level: number; + error?: string; + start: () => Promise; + pause: () => void; + resume: () => void; + /** Stop and return the recording (null if nothing captured / unsupported). */ + stop: () => Promise; +} + +function isSupported(): boolean { + return ( + Platform.OS === 'web' && + typeof navigator !== 'undefined' && + !!navigator.mediaDevices?.getUserMedia && + typeof MediaRecorder !== 'undefined' + ); +} + +export function useRecorder(): Recorder { + const supported = isSupported(); + const [status, setStatus] = useState('idle'); + const [elapsedMs, setElapsedMs] = useState(0); + const [level, setLevel] = useState(0); + const [error, setError] = useState(undefined); + + const recorderRef = useRef(null); + const chunksRef = useRef([]); + const streamRef = useRef(null); + const audioCtxRef = useRef(null); + const analyserRef = useRef(null); + const rafRef = useRef(null); + const wakeRef = useRef(null); + // Elapsed bookkeeping across pause/resume. + const segStartRef = useRef(0); + const accumRef = useRef(0); + + const cleanup = useCallback(() => { + if (rafRef.current != null) cancelAnimationFrame(rafRef.current); + rafRef.current = null; + streamRef.current?.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + void audioCtxRef.current?.close().catch(() => {}); + audioCtxRef.current = null; + analyserRef.current = null; + void wakeRef.current?.release().catch(() => {}); + wakeRef.current = null; + }, []); + + useEffect(() => cleanup, [cleanup]); + + const tick = useCallback(() => { + // Elapsed: accumulated paused time + current running segment. + const running = status === 'recording' ? Date.now() - segStartRef.current : 0; + setElapsedMs(accumRef.current + running); + + const an = analyserRef.current; + if (an) { + const buf = new Uint8Array(an.fftSize); + an.getByteTimeDomainData(buf); + let sum = 0; + for (let i = 0; i < buf.length; i++) { + const v = (buf[i]! - 128) / 128; + sum += v * v; + } + setLevel(Math.min(1, Math.sqrt(sum / buf.length) * 3)); + } + rafRef.current = requestAnimationFrame(tick); + }, [status]); + + // Keep the rAF loop bound to the latest `status` via tick's identity. + useEffect(() => { + if (status === 'recording' || status === 'paused') { + rafRef.current = requestAnimationFrame(tick); + return () => { + if (rafRef.current != null) cancelAnimationFrame(rafRef.current); + }; + } + return undefined; + }, [status, tick]); + + const start = useCallback(async () => { + if (!supported) { + setError('Recording is only available in the browser for now.'); + return; + } + try { + setError(undefined); + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + streamRef.current = stream; + + const mr = new MediaRecorder(stream); + chunksRef.current = []; + mr.ondataavailable = (e) => { + if (e.data && e.data.size > 0) chunksRef.current.push(e.data); + }; + mr.start(); + recorderRef.current = mr; + + // Level metering off an AnalyserNode. + const ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)(); + const src = ctx.createMediaStreamSource(stream); + const an = ctx.createAnalyser(); + an.fftSize = 512; + src.connect(an); + audioCtxRef.current = ctx; + analyserRef.current = an; + + // Keep the screen awake while recording (best-effort). + try { + wakeRef.current = (await navigator.wakeLock?.request('screen')) ?? null; + } catch { + /* wake lock optional */ + } + + accumRef.current = 0; + segStartRef.current = Date.now(); + setElapsedMs(0); + setStatus('recording'); + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not start recording (mic permission?).'); + cleanup(); + setStatus('idle'); + } + }, [supported, cleanup]); + + const pause = useCallback(() => { + const mr = recorderRef.current; + if (!mr || status !== 'recording') return; + mr.pause(); + accumRef.current += Date.now() - segStartRef.current; + setStatus('paused'); + }, [status]); + + const resume = useCallback(() => { + const mr = recorderRef.current; + if (!mr || status !== 'paused') return; + mr.resume(); + segStartRef.current = Date.now(); + setStatus('recording'); + }, [status]); + + const stop = useCallback(async (): Promise => { + const mr = recorderRef.current; + if (!mr) return null; + const mime = mr.mimeType || 'audio/webm'; + const blob: Blob = await new Promise((resolve) => { + mr.onstop = () => resolve(new Blob(chunksRef.current, { type: mime })); + mr.stop(); + }); + recorderRef.current = null; + cleanup(); + setStatus('stopped'); + if (blob.size === 0) return null; + const data = await blob.arrayBuffer(); + const ext = mime.includes('ogg') ? 'ogg' : mime.includes('mp4') ? 'mp4' : 'webm'; + return { data, mime, name: `Recording.${ext}` }; + }, [cleanup]); + + return { supported, status, elapsedMs, level, error, start, pause, resume, stop }; +}