import Voice from "@react-native-voice/voice"; import { router, useFocusEffect } from "expo-router"; import { useCallback, useEffect, useRef, useState } from "react"; import Svg, { Path } from "react-native-svg"; import { ActivityIndicator, Alert, KeyboardAvoidingView, NativeModules, 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")}`; } export default function RecorderScreen() { const [backendUrl, setBackendUrl] = useState(""); const [authToken, setAuthToken] = useState(""); const [locale, setLocale] = useState("ca"); const [strings, setStrings] = useState(() => getStrings("ca")); const [transcript, setTranscript] = useState(""); const [interimTranscript, setInterimTranscript] = useState(""); const [statusMessage, setStatusMessage] = useState(""); const [responsePreview, setResponsePreview] = useState(""); const [isSending, setIsSending] = useState(false); const [isHolding, setIsHolding] = useState(false); const [listeningMs, setListeningMs] = useState(0); const startRef = useRef(0); const timerRef = useRef | null>(null); const listeningActiveRef = useRef(false); const refreshSettings = useCallback(() => { let isMounted = true; async function loadStoredValues() { try { const settings = await loadRecorderSettings(); if (!isMounted) { return; } setBackendUrl(settings.backendUrl); setAuthToken(settings.authToken); setLocale(settings.language); setStrings(getStrings(settings.language)); } catch { if (isMounted) { setStatusMessage(strings.loadError); } } } void loadStoredValues(); return () => { isMounted = false; }; }, []); useFocusEffect(refreshSettings); useEffect(() => { if (Platform.OS === "web" || !NativeModules.Voice) { return; } Voice.onSpeechStart = (e) => { console.log("Voice.onSpeechStart", e); setTranscript(""); setInterimTranscript(""); setStatusMessage(strings.recording); startRef.current = Date.now(); timerRef.current = setInterval(() => { const elapsed = Date.now() - startRef.current; setListeningMs(elapsed); }, 250); }; Voice.onSpeechEnd = (e) => { console.log("Voice.onSpeechEnd", e); listeningActiveRef.current = false; if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } }; Voice.onSpeechResults = (e) => { console.log("Voice.onSpeechResults", e); const values = (e as unknown as { value: string[] }).value; if (values && values.length > 0) { const last = values[values.length - 1]; setTranscript(last); } }; Voice.onSpeechPartialResults = (e) => { console.log("Voice.onSpeechPartialResults", e); const values = (e as unknown as { value: string[] }).value; if (values && values.length > 0) { const last = values[values.length - 1]; setInterimTranscript(last || ""); } }; Voice.onSpeechError = (e) => { console.log("Voice.onSpeechError", e); const message = (e as unknown as { error?: { message: string } }).error?.message || "Speech recognition error"; setStatusMessage(message); if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } setListeningMs(0); listeningActiveRef.current = false; Alert.alert(strings.recordingFailedTitle, message); }; return () => { Voice.removeAllListeners(); if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } }; }, []); useEffect(() => { return () => { if (listeningActiveRef.current && Platform.OS !== "web" && NativeModules.Voice) { Voice.stop().catch(() => undefined); listeningActiveRef.current = false; } }; }, []); async function startListening() { try { setResponsePreview(""); setTranscript(""); setInterimTranscript(""); const localeCode = locale.includes("ca") ? "ca-ES" : locale.includes("es") ? "es-ES" : "en-US"; setIsHolding(true); if (Platform.OS === "web") { console.log("Voice not available on web"); Alert.alert("Not supported", "Speech recognition is only available on mobile devices. Open the app on Android or iOS."); setIsHolding(false); return; } if (!NativeModules.Voice) { Alert.alert("Not supported", "Speech recognition module not found. Make sure the app is built with native modules."); setIsHolding(false); return; } await Voice.start(localeCode); listeningActiveRef.current = true; } catch (error) { const msg = error instanceof Error ? error.message : "Failed to start speech recognition"; console.error("Voice.start failed:", error); Alert.alert(strings.recordingFailedTitle, msg); } } async function stopListeningAndSend() { setIsHolding(false); const wasListening = listeningActiveRef.current; if (!wasListening) { setStatusMessage(transcript ? strings.voiceMessageSent : strings.readyToRecord); return; } listeningActiveRef.current = false; if (Platform.OS === "web") { const finalText = (transcript + " " + interimTranscript).trim().replace(/\s+/g, " "); if (finalText) { await sendCommand(finalText); } else { setStatusMessage(strings.readyToRecord); } return; } try { await Voice.stop(); const finalText = (transcript + " " + interimTranscript).trim().replace(/\s+/g, " "); if (!finalText) { setStatusMessage(strings.readyToRecord); return; } await sendCommand(finalText); } catch (error) { listeningActiveRef.current = false; const msg = error instanceof Error ? error.message : "Stop failed"; console.error("Voice.stop failed:", error); setStatusMessage(msg); } } async function sendCommand(text: string) { const trimmedUrl = backendUrl.trim().replace(/\/+$/, ""); const commandUrl = trimmedUrl.endsWith("/commands") ? `${trimmedUrl}/text` : `${trimmedUrl}/commands/text`; if (!commandUrl) { setStatusMessage(strings.noBackendUrl); return; } try { setIsSending(true); setStatusMessage(strings.uploadingRecording); const headers: Record = { "Content-Type": "application/json", }; if (authToken.trim()) { headers.Authorization = `Bearer ${authToken.trim()}`; } const response = await fetch(commandUrl, { method: "POST", headers, body: JSON.stringify({ text }), }); const responseText = await response.text(); setResponsePreview(responseText.slice(0, 400)); setTranscript(""); setInterimTranscript(""); if (!response.ok) { throw new Error(`${response.status}. ${responseText}`); } setStatusMessage(strings.voiceMessageSent); } catch (error) { setStatusMessage(strings.uploadFailed); Alert.alert( strings.uploadFailed, error instanceof Error ? error.message : "", ); } finally { setIsSending(false); } } async function handlePressIn() { if (isSending) return; await startListening(); } async function handlePressOut() { await stopListeningAndSend(); } const releaseLabel = t("releaseToStop", locale); const holdLabel = t("holdToRecord", locale); const openSettingsHint = t("openSettingsHint", locale); const appTitleLabel = t("appTitle", locale); const recorderTitleLabel = t("recorderTitle", locale); const serverResponseLabel = t("serverResponse", locale); const displayText = interimTranscript || transcript; return ( {appTitleLabel} router.push("/settings")} hitSlop={10} style={styles.settingsCog} > {formatDuration(listeningMs)} {isSending ? ( ) : ( )} {statusMessage || strings.readyToRecord} {isHolding ? releaseLabel : backendUrl.trim() ? holdLabel : openSettingsHint} {displayText && ( {strings.serverResponse} {displayText} )} ); } 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 }], }, buttonDisabled: { opacity: 0.45, }, statusText: { color: "#1f2d3d", fontSize: 15, lineHeight: 21, textAlign: "center", }, helperText: { color: "#665f54", fontSize: 13, lineHeight: 18, textAlign: "center", }, transcriptBox: { backgroundColor: "#f7f0e0", borderRadius: 16, gap: 6, marginTop: 4, padding: 14, maxWidth: "100%", }, transcriptLabel: { color: "#13304a", fontSize: 13, fontWeight: "700", textTransform: "uppercase", textAlign: "center", }, transcriptText: { color: "#36475a", fontSize: 16, lineHeight: 22, textAlign: "center", fontWeight: "500", }, 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, }, });