// apps/mobile/app/(tabs)/record.tsx // 녹음 — expo-av Audio.Recording 사용 import { useState, useRef } from 'react' import { View, Text, Pressable, StyleSheet, Alert, ActivityIndicator } from 'react-native' import { Audio } from 'expo-av' import { supabase, isSupabaseConfigured } from '../../lib/supabase' import Constants from 'expo-constants' 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 } // Edge Function 호출 const { data: { session } } = await supabase.auth.getSession() if (!session) { setError('로그인이 필요합니다') setState('error') return } const formData = new FormData() // RN의 FormData는 { uri, name, type } 형태 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) } return ( {state === 'recording' ? 'RECORDING' : state === 'processing' ? 'PROCESSING' : 'READY'} {state === 'processing' && } {transcript && state === 'done' && ( {transcript} )} {error && ( {error} )} {(state === 'idle' || state === 'done' || state === 'error') && ( void startRecording()}> {state === 'done' ? '새 녹음' : '녹음 시작'} )} {state === 'recording' && ( void stopRecording()}> 정지 void cancelRecording()}> 취소 )} ) } const styles = StyleSheet.create({ container: { flex: 1, padding: 32, alignItems: 'center', justifyContent: 'center' }, title: { color: '#f25b29', fontSize: 20, fontWeight: '600', letterSpacing: 2, marginBottom: 32 }, spinner: { marginVertical: 24 }, transcriptBox: { backgroundColor: '#242427', padding: 16, borderRadius: 8, marginBottom: 24, width: '100%' }, transcript: { color: '#ffffff', fontSize: 14, lineHeight: 20 }, errorBox: { borderWidth: 1, borderColor: '#ef4444', padding: 12, borderRadius: 8, marginBottom: 24 }, error: { color: '#ef4444', fontSize: 12 }, buttons: { width: '100%', alignItems: 'center' }, bigButton: { backgroundColor: '#f25b29', paddingVertical: 18, paddingHorizontal: 64, borderRadius: 12, minWidth: 200, alignItems: 'center' }, bigButtonText: { color: '#ffffff', fontSize: 16, fontWeight: '600', letterSpacing: 1 }, stop: { backgroundColor: '#ef4444' }, cancelButton: { paddingVertical: 12, alignItems: 'center' }, cancelText: { color: '#8e8e93', fontSize: 13 } })