import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av"; import { router, useFocusEffect } from "expo-router"; import { useCallback, useEffect, useRef, useState } from "react"; import Svg, { Path } from "react-native-svg"; import { ActivityIndicator, Alert, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, View, } from "react-native"; import { loadRecorderSettings } from "@/lib/recorder-settings"; import { getStrings, type Locale, t } from "@/lib/translations"; function formatDuration(durationMs: number) { const totalSeconds = Math.floor(durationMs / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes.toString().padStart(2, "0")}:${seconds .toString() .padStart(2, "0")}`; } function buildMimeType(uri: string) { const extension = uri.split(".").pop()?.split("?")[0]?.toLowerCase(); switch (extension) { case "wav": return "audio/wav"; case "caf": return "audio/x-caf"; case "webm": return "audio/webm"; case "mp3": return "audio/mpeg"; default: return "audio/m4a"; } } function buildFileExtension(uri: string) { return uri.split(".").pop()?.split("?")[0]?.toLowerCase() || "m4a"; } export default function RecorderScreen() { const [backendUrl, setBackendUrl] = useState(""); const [authToken, setAuthToken] = useState(""); const [fieldName, setFieldName] = useState("file"); const [locale, setLocale] = useState("ca"); const [strings, setStrings] = useState(() => getStrings("ca")); const [recording, setRecording] = useState(null); const [recordingUri, setRecordingUri] = useState(null); const [recordingMs, setRecordingMs] = useState(0); const [statusMessage, setStatusMessage] = useState(""); const [responsePreview, setResponsePreview] = useState(""); const [llmResponseText, setLlmResponseText] = useState(""); const [transcriptionText, setTranscriptionText] = useState(""); const [isUploading, setIsUploading] = useState(false); const [isHolding, setIsHolding] = useState(false); const [isPlaying, setIsPlaying] = useState(false); const recordingRef = useRef(null); const soundRef = useRef(null); const refreshSettings = useCallback(() => { let isMounted = true; async function loadStoredValues() { try { const settings = await loadRecorderSettings(); if (!isMounted) { return; } setBackendUrl(settings.backendUrl); setAuthToken(settings.authToken); setFieldName(settings.fieldName); setLocale(settings.language); setStrings(getStrings(settings.language)); } catch { if (isMounted) { setStatusMessage(strings.loadError); } } } void loadStoredValues(); return () => { isMounted = false; }; }, []); useFocusEffect(refreshSettings); useEffect(() => { if (!recording) { return; } const interval = setInterval(() => { void recording.getStatusAsync().then((status) => { if (typeof status.durationMillis === "number") { setRecordingMs(status.durationMillis ?? 0); } }); }, 250); return () => { clearInterval(interval); }; }, [recording]); useEffect(() => { return () => { if (recording) { void recording.stopAndUnloadAsync().catch(() => undefined); } }; }, [recording]); useEffect(() => { return () => { void unloadSound(); }; }, []); async function unloadSound() { if (soundRef.current) { try { await soundRef.current.stopAsync(); await soundRef.current.unloadAsync(); } catch (err) { console.log("[TTS] Error unloading sound:", err); } soundRef.current = null; } setIsPlaying(false); } async function speakWithAudio(audioUrl: string, backendBase: string) { if (!audioUrl) return false; await unloadSound(); try { await Audio.setAudioModeAsync({ allowsRecordingIOS: false, playsInSilentModeIOS: true, interruptionModeAndroid: InterruptionModeAndroid.DoNotMix, interruptionModeIOS: InterruptionModeIOS.DoNotMix, shouldDuckAndroid: true, staysActiveInBackground: false, }); } catch (err) { console.log("[TTS] Audio mode error:", err); } try { const fullUrl = audioUrl.startsWith("http") ? audioUrl : `${backendBase.replace(/\/+$/, "")}/${audioUrl.replace(/^\/+/, "")}`; console.log("[TTS] Loading audio from:", fullUrl); setIsPlaying(true); setStatusMessage(strings.playing); const { sound } = await Audio.Sound.createAsync( { uri: fullUrl }, { shouldPlay: true, volume: 1.0 }, (status) => { if (status.isLoaded && status.didJustFinish) { console.log("[TTS] Audio playback finished"); void unloadSound(); } }, ); soundRef.current = sound; const status = await sound.getStatusAsync(); const durationMs = status.isLoaded ? (status.durationMillis ?? 0) : 0; console.log("[TTS] Playing audio, duration:", durationMs, "ms"); return true; } catch (err) { console.log("[TTS] Audio playback error:", err); setIsPlaying(false); return false; } } async function speakSequentially(texts: string[]) { if (texts.length === 0) return; const trimmedUrl = backendUrl.trim().replace(/\/+$/, ""); for (let i = 0; i < texts.length; i++) { const text = texts[i]; if (!text || !text.trim()) continue; try { setStatusMessage(strings.playing); console.log("[TTS] Generating TTS audio for text:", text.substring(0, 50)); const localeLang = locale === "ca" ? "ca" : "en"; const ttsParams = new URLSearchParams({ text: text.trim(), language: localeLang, }); if (authToken.trim()) { ttsParams.append("token", authToken.trim()); } const ttsUrl = `${trimmedUrl}/tts?${ttsParams.toString()}`; const ttsResponse = await fetch(ttsUrl, { method: "POST" }); if (!ttsResponse.ok) { const errText = await ttsResponse.text(); console.log("[TTS] TTS endpoint error:", ttsResponse.status, errText); continue; } const ttsData = await ttsResponse.json(); if (!ttsData.audioUrl) { console.log("[TTS] No audioUrl in response:", ttsData); continue; } const played = await speakWithAudio(ttsData.audioUrl, trimmedUrl); if (!played) { setStatusMessage(strings.uploadFailed); } if (i < texts.length - 1) { await new Promise((r) => setTimeout(r, 800)); } } catch (err) { console.log("[TTS] speakSequentially error:", err); } } } async function speak(text: string) { const texts = [text].filter(Boolean); await speakSequentially(texts); } async function startRecording() { try { await unloadSound(); setTranscriptionText(""); setResponsePreview(""); setLlmResponseText(""); setRecordingUri(null); const permission = await Audio.requestPermissionsAsync(); if (!permission.granted) { setStatusMessage(strings.micPermissionDenied); Alert.alert( strings.micAccessRequiredTitle, strings.micAccessRequiredMsg, ); return; } await Audio.setAudioModeAsync({ allowsRecordingIOS: true, interruptionModeAndroid: InterruptionModeAndroid.DoNotMix, interruptionModeIOS: InterruptionModeIOS.DoNotMix, playsInSilentModeIOS: true, shouldDuckAndroid: true, staysActiveInBackground: false, }); const result = await Audio.Recording.createAsync( Audio.RecordingOptionsPresets.HIGH_QUALITY, ); recordingRef.current = result.recording; setRecording(result.recording); setRecordingMs(0); setStatusMessage(strings.recording); } catch (error) { setStatusMessage(strings.couldNotStartRecording); Alert.alert( strings.recordingFailedTitle, error instanceof Error ? error.message : "", ); } } async function stopRecordingAndUpload() { if (!recordingRef.current) { return; } console.log("[APP] stopRecordingAndUpload called"); try { const activeRecording = recordingRef.current; const currentStatus = await activeRecording.getStatusAsync(); const durationMillis = currentStatus.durationMillis ?? 0; await activeRecording.stopAndUnloadAsync(); await Audio.setAudioModeAsync({ allowsRecordingIOS: false, playsInSilentModeIOS: true, }); const uri = activeRecording.getURI(); recordingRef.current = null; setRecording(null); setRecordingMs(durationMillis); if (!uri) { setStatusMessage(strings.readyToRecord); return; } setRecordingUri(uri); setStatusMessage(strings.finishedUpload); const trimmedUrl = backendUrl.trim().replace(/\/+$/, ''); const uploadUrl = trimmedUrl.endsWith('/audio/upload') ? trimmedUrl : `${trimmedUrl}/audio/upload`; if (uploadUrl) { setIsUploading(true); try { const mimeType = buildMimeType(uri); const extension = buildFileExtension(uri); const formData = new FormData(); formData.append(fieldName.trim() || "file", { name: `recording-${Date.now()}.${extension}`, type: mimeType, uri: uri, } as never); const headers: Record = {}; if (authToken.trim()) { headers.Authorization = `Bearer ${authToken.trim()}`; } const response = await fetch(uploadUrl, { method: "POST", headers, body: formData, }); const responseText = await response.text(); if (!response.ok) { throw new Error(`${response.status}. ${responseText}`); } try { const data = JSON.parse(responseText); setResponsePreview(responseText.slice(0, 400)); const textsToSpeak: string[] = []; if (data.transcription) { setTranscriptionText(data.transcription); } if (data.llmResponse) { setLlmResponseText(data.llmResponse); textsToSpeak.push(data.llmResponse); } if (textsToSpeak.length > 0) { setStatusMessage(strings.voiceMessageSent + ". " + strings.playing); void speakSequentially(textsToSpeak); } else { setLlmResponseText(""); } } catch (parseError) { console.log("[APP] JSON parse failed:", parseError, "Response was:", responseText.substring(0, 200)); setResponsePreview(responseText.slice(0, 400)); setTranscriptionText(""); setLlmResponseText(""); } setStatusMessage(strings.voiceMessageSent); } catch (error) { setStatusMessage(strings.uploadFailed); Alert.alert( strings.uploadFailed, error instanceof Error ? error.message : "", ); } finally { setIsUploading(false); } } else { setStatusMessage(strings.noBackendUrl); setIsUploading(false); } } catch (error) { recordingRef.current = null; setRecording(null); setStatusMessage(strings.stopFailedTitle); Alert.alert( strings.stopFailedTitle, error instanceof Error ? error.message : "", ); } } async function handlePressIn() { if (isUploading) return; setIsHolding(true); await startRecording(); } async function handlePressOut() { if (!isHolding) return; setIsHolding(false); await stopRecordingAndUpload(); } async function uploadRecording(uriOverride?: string) { const targetUri = uriOverride ?? recordingUri; if (!targetUri) { return; } const trimmedUrl = backendUrl.trim().replace(/\/+$/, ''); const uploadUrl = trimmedUrl.endsWith('/audio/upload') ? trimmedUrl : `${trimmedUrl}/audio/upload`; if (!uploadUrl) { Alert.alert(strings.missingBackendUrlTitle, strings.missingBackendUrlMsg); return; } try { setIsUploading(true); setStatusMessage(strings.uploadingRecording); await unloadSound(); setTranscriptionText(""); setResponsePreview(""); setLlmResponseText(""); const mimeType = buildMimeType(targetUri); const extension = buildFileExtension(targetUri); const formData = new FormData(); formData.append(fieldName.trim() || "file", { name: `recording-${Date.now()}.${extension}`, type: mimeType, uri: targetUri, } as never); const headers: Record = {}; if (authToken.trim()) { headers.Authorization = `Bearer ${authToken.trim()}`; } const response = await fetch(uploadUrl, { method: "POST", headers, body: formData, }); const responseText = await response.text(); if (!response.ok) { throw new Error(`${response.status}. ${responseText}`); } try { const data = JSON.parse(responseText); setResponsePreview(responseText.slice(0, 400)); const textsToSpeak: string[] = []; if (data.transcription) { setTranscriptionText(data.transcription); textsToSpeak.push(data.transcription); } if (data.llmResponse) { setLlmResponseText(data.llmResponse); textsToSpeak.push(data.llmResponse); } if (textsToSpeak.length > 0) { setStatusMessage(strings.voiceMessageSent + ". " + strings.playing); void speakSequentially(textsToSpeak); } else { setLlmResponseText(""); } } catch { setResponsePreview(responseText.slice(0, 400)); setTranscriptionText(""); setLlmResponseText(""); } setStatusMessage(strings.uploadComplete); } catch (error) { setStatusMessage(strings.uploadFailed); Alert.alert( strings.uploadFailed, error instanceof Error ? error.message : "", ); } finally { setIsUploading(false); } } function handleSpeak() { const texts = [transcriptionText, llmResponseText].filter(Boolean); void speakSequentially(texts); } const releaseLabel = t("releaseToStop", locale); const holdLabel = t("holdToRecord", locale); const openSettingsLabel = t("openSettingsHint", locale); const appTitleLabel = t("appTitle", locale); const recorderTitleLabel = t("recorderTitle", locale); const serverResponseLabel = t("serverResponse", locale); return ( {appTitleLabel} router.push("/settings")} hitSlop={10} style={styles.settingsCog} > {formatDuration(recordingMs)} {isUploading ? ( ) : ( )} {statusMessage || strings.readyToRecord} {isHolding ? releaseLabel : backendUrl.trim() ? holdLabel : openSettingsLabel} {transcriptionText ? ( {strings.yourMessage} {transcriptionText} ) : null} {llmResponseText ? ( {strings.aiReply} {llmResponseText} ) : null} {responsePreview && !transcriptionText && !llmResponseText ? ( {serverResponseLabel} {responsePreview} ) : null} ); } const styles = StyleSheet.create({ safeArea: { flex: 1, backgroundColor: "#f4efe4", }, keyboardAvoidingView: { flex: 1, }, scrollView: { flex: 1, }, content: { flex: 1, alignItems: "center", justifyContent: "center", paddingVertical: 32, paddingHorizontal: 20, gap: 18, }, hero: { backgroundColor: "transparent", paddingHorizontal: 22, paddingTop: 40, }, heroTopRow: { alignItems: "center", flexDirection: "row", justifyContent: "space-between", }, heroBadge: { backgroundColor: "#f2b15d", borderRadius: 999, paddingHorizontal: 12, paddingVertical: 6, }, heroBadgeText: { color: "#13304a", fontSize: 12, fontWeight: "700", letterSpacing: 0.5, textTransform: "uppercase", }, settingsCog: { alignItems: "center", justifyContent: "center", width: 40, height: 40, borderRadius: 999, backgroundColor: "#13304a", marginLeft: 12, }, panel: { backgroundColor: "#fffaf1", borderColor: "#dccfb9", borderRadius: 24, borderWidth: 1, gap: 12, padding: 18, alignSelf: "center", maxWidth: 340, }, meterValueCentered: { color: "#d04f2d", fontSize: 40, fontWeight: "800", textAlign: "center", }, micButton: { alignItems: "center", borderRadius: 999, height: 164, justifyContent: "center", marginVertical: 6, width: 164, alignSelf: "center", }, idleButton: { backgroundColor: "#13304a", }, holdingButton: { backgroundColor: "#d04f2d", transform: [{ scale: 1.08 }], }, micButtonText: { color: "#fff6f3", fontSize: 20, fontWeight: "800", }, recordingLabel: { fontSize: 18, }, buttonDisabled: { opacity: 0.45, }, statusText: { color: "#1f2d3d", fontSize: 15, lineHeight: 21, textAlign: "center", }, helperText: { color: "#665f54", fontSize: 13, lineHeight: 18, textAlign: "center", }, responseBox: { backgroundColor: "#f7f0e0", borderRadius: 16, gap: 6, marginTop: 4, padding: 14, }, responseLabel: { color: "#13304a", fontSize: 13, fontWeight: "700", textTransform: "uppercase", textAlign: "center", }, responseText: { color: "#36475a", fontSize: 14, lineHeight: 20, }, llmResponseBox: { backgroundColor: "#e8f4e8", borderRadius: 16, gap: 6, marginTop: 4, padding: 14, borderWidth: 1, borderColor: "#b8d9b8", }, llmResponseHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", }, llmResponseLabel: { color: "#2a6a2a", fontSize: 13, fontWeight: "700", textTransform: "uppercase", textAlign: "center", }, llmResponseText: { color: "#2d4a2d", fontSize: 16, lineHeight: 24, }, speakButton: { backgroundColor: "#f7f0e0", borderRadius: 20, padding: 6, borderWidth: 1, borderColor: "#dccfb9", }, transcriptionBox: { backgroundColor: "#e8ecf4", borderRadius: 16, gap: 6, marginTop: 4, padding: 14, borderWidth: 1, borderColor: "#b8c9d9", }, transcriptionLabel: { color: "#1a4a6a", fontSize: 13, fontWeight: "700", textTransform: "uppercase", textAlign: "center", }, transcriptionText: { color: "#1f3a52", fontSize: 16, lineHeight: 24, }, });