// apps/mobile/app/(tabs)/record.tsx // 녹음 탭 — expo-av 기반 녹음 + STT 파이프라인 // Phase M-2에서 디자인(docs/v3/designs/recording.html) 정밀 적용 import { useState, useRef } from 'react' import { View, StyleSheet, ActivityIndicator, ScrollView } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Audio } from 'expo-av' import Constants from 'expo-constants' import { MetalCard, PhosphorText, PhysicalButton, Led, d3roNativePalette } from '@d3ro/ui-native' import { supabase, isSupabaseConfigured } from '../../lib/supabase' type RecordingState = 'idle' | 'recording' | 'processing' | 'done' | 'error' export default function RecordScreen(): React.ReactElement { const insets = useSafeAreaInsets() const [state, setState] = useState('idle') const [transcript, setTranscript] = useState('') const [error, setError] = useState(null) const [duration, setDuration] = useState(0) const recordingRef = useRef(null) const timerRef = useRef | null>(null) async function startRecording(): Promise { setError(null) setTranscript('') setDuration(0) try { const perm = await Audio.requestPermissionsAsync() if (perm.status !== 'granted') { setError('Microphone permission denied') setState('error') return } await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true }) const recording = new Audio.Recording() await recording.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY) await recording.startAsync() recordingRef.current = recording setState('recording') timerRef.current = setInterval(() => { setDuration((d) => d + 1) }, 1000) } catch (e) { setError(e instanceof Error ? e.message : 'Failed to start') setState('error') } } async function stopRecording(): Promise { if (!recordingRef.current) return if (timerRef.current) { clearInterval(timerRef.current) timerRef.current = null } setState('processing') try { await recordingRef.current.stopAndUnloadAsync() const uri = recordingRef.current.getURI() recordingRef.current = null if (!uri) { throw new Error('No recording URI') } if (!isSupabaseConfigured()) { setError('Supabase not configured') setState('error') return } const { data: { session } } = await supabase.auth.getSession() if (!session) { setError('Login required') setState('error') return } const formData = new FormData() formData.append('audio', { uri, name: 'recording.m4a', type: 'audio/m4a' } as unknown as Blob) formData.append('language_code', 'ko-KR') const url = (Constants.expoConfig?.extra?.supabaseUrl as string) ?? '' const response = await fetch(`${url}/functions/v1/stt-proxy`, { method: 'POST', headers: { Authorization: `Bearer ${session.access_token}` }, body: formData }) if (!response.ok) { throw new Error(`STT failed: ${response.status}`) } const result = (await response.json()) as { transcript: string } setTranscript(result.transcript) setState('done') } catch (e) { setError(e instanceof Error ? e.message : 'Unknown error') setState('error') } } async function cancelRecording(): Promise { if (timerRef.current) { clearInterval(timerRef.current) timerRef.current = null } if (recordingRef.current) { try { await recordingRef.current.stopAndUnloadAsync() } catch { /* ignore */ } recordingRef.current = null } setState('idle') setTranscript('') setError(null) setDuration(0) } const formatTime = (s: number): string => { const m = Math.floor(s / 60) const sec = s % 60 return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` } return ( {/* Header */} {state === 'recording' ? 'REC_SESSION' : 'RECORDER'} {state === 'recording' && ( {formatTime(duration)} )} {/* Wave Bars Placeholder */} {state === 'recording' && ( {Array.from({ length: 11 }).map((_, i) => ( ))} )} {state === 'recording' ? 'RECORDING' : state === 'processing' ? 'PROCESSING' : state === 'done' ? 'DONE' : state === 'error' ? 'ERROR' : 'READY'} {state === 'processing' && ( )} {transcript && state === 'done' && ( {transcript} )} {error && ( {error} )} {(state === 'idle' || state === 'done' || state === 'error') && ( void startRecording()} /> )} {state === 'recording' && ( <> void stopRecording()} /> void cancelRecording()} /> )} ) } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: d3roNativePalette.bg.app }, content: { paddingBottom: 100 }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 20, paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: 'rgba(46, 46, 50, 0.5)' }, headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 }, waveContainer: { height: 128, backgroundColor: d3roNativePalette.bg.inset, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, borderBottomWidth: 1, borderBottomColor: 'rgba(26, 26, 28, 1)' }, waveBar: { width: 6, backgroundColor: d3roNativePalette.accent.amber, borderRadius: 3 }, card: { margin: 20, padding: 24 }, statusRow: { flexDirection: 'row', alignItems: 'center' }, statusLabel: { marginLeft: 8 }, center: { alignItems: 'center', minHeight: 140, justifyContent: 'center', marginVertical: 16 }, transcriptBox: { backgroundColor: d3roNativePalette.bg.inset, padding: 12, borderRadius: 8, width: '100%', marginTop: 8 }, errorBox: { borderWidth: 1, borderColor: d3roNativePalette.tag.red, padding: 10, borderRadius: 6, width: '100%', marginTop: 8 }, buttons: { marginTop: 16 } })