// apps/mobile/app/(tabs)/record.tsx // 녹음 — expo-av + @d3ro/ui-native import { useState, useRef } from 'react' import { View, StyleSheet, ActivityIndicator, ScrollView } from 'react-native' 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 [state, setState] = useState('idle') const [transcript, setTranscript] = useState('') const [error, setError] = useState(null) const recordingRef = useRef(null) async function startRecording(): Promise { setError(null) setTranscript('') try { const perm = await Audio.requestPermissionsAsync() if (perm.status !== 'granted') { setError('마이크 권한이 거부되었습니다') 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') } catch (e) { setError(e instanceof Error ? e.message : 'Failed to start') setState('error') } } async function stopRecording(): Promise { if (!recordingRef.current) return setState('processing') try { await recordingRef.current.stopAndUnloadAsync() const uri = recordingRef.current.getURI() recordingRef.current = null if (!uri) { throw new Error('녹음 파일 URI를 받지 못했습니다') } if (!isSupabaseConfigured()) { setError('Supabase가 설정되지 않아 전사할 수 없습니다') setState('error') return } const { data: { session } } = await supabase.auth.getSession() if (!session) { setError('로그인이 필요합니다') 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 실패: ${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 (recordingRef.current) { try { await recordingRef.current.stopAndUnloadAsync() } catch { // ignore } recordingRef.current = null } setState('idle') setTranscript('') setError(null) } const statusLed: 'amber' | 'red' | 'orange' | 'green' = state === 'recording' ? 'red' : state === 'processing' ? 'orange' : state === 'done' ? 'green' : 'amber' return ( {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: { padding: 24 }, card: { 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 } })