import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av"; import { router, useFocusEffect } from "expo-router"; import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, Alert, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { loadRecorderSettings } from "@/lib/recorder-settings"; 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 [recording, setRecording] = useState(null); const [recordingUri, setRecordingUri] = useState(null); const [recordingMs, setRecordingMs] = useState(0); const [statusMessage, setStatusMessage] = useState( "Ready to record and send audio.", ); const [responsePreview, setResponsePreview] = useState(""); const [isUploading, setIsUploading] = useState(false); 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); } catch { if (isMounted) { setStatusMessage("Could not load saved backend settings."); } } } 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]); async function startRecording() { try { setResponsePreview(""); setRecordingUri(null); const permission = await Audio.requestPermissionsAsync(); if (!permission.granted) { setStatusMessage("Microphone permission was denied."); Alert.alert( "Microphone access required", "Enable microphone access to record audio.", ); 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, ); setRecording(result.recording); setRecordingMs(0); setStatusMessage("Recording in progress."); } catch (error) { setStatusMessage("Recording could not be started."); Alert.alert( "Recording failed", error instanceof Error ? error.message : "Unknown recording error.", ); } } async function stopRecording() { if (!recording) { return; } try { const activeRecording = recording; const currentStatus = await activeRecording.getStatusAsync(); await activeRecording.stopAndUnloadAsync(); await Audio.setAudioModeAsync({ allowsRecordingIOS: false, playsInSilentModeIOS: true, }); const uri = activeRecording.getURI(); setRecording(null); setRecordingMs(currentStatus.durationMillis ?? recordingMs); setRecordingUri(uri); setStatusMessage( uri ? "Recording finished. Preparing to send voice message." : "Recording finished.", ); if (uri && backendUrl.trim()) { await uploadRecording(uri); } } catch (error) { setStatusMessage("Recording could not be stopped cleanly."); Alert.alert( "Stop failed", error instanceof Error ? error.message : "Unknown stop error.", ); } } async function uploadRecording(uriOverride?: string) { const targetUri = uriOverride ?? recordingUri; if (!targetUri) { return; } const trimmedUrl = backendUrl.trim(); if (!trimmedUrl) { Alert.alert("Missing backend URL", "Enter the backend endpoint first."); return; } try { setIsUploading(true); setStatusMessage("Uploading recording."); setResponsePreview(""); 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(trimmedUrl, { method: "POST", headers, body: formData, }); const responseText = await response.text(); setResponsePreview(responseText.slice(0, 400)); if (!response.ok) { throw new Error(`Upload failed with ${response.status}. ${responseText}`); } setStatusMessage("Upload complete."); } catch (error) { setStatusMessage("Upload failed."); Alert.alert( "Upload failed", error instanceof Error ? error.message : "Unknown upload error.", ); } finally { setIsUploading(false); } } return ( Assistant Voice router.push("/settings")} style={styles.settingsLink}> Settings Record a voice message and send it to your backend. {formatDuration(recordingMs)} {isUploading ? ( ) : ( {recording ? "Stop" : "Record"} )} {statusMessage} {backendUrl.trim() ? recording ? "Tap again when you finish speaking." : "Tap the button to start a new voice message." : "Open settings to add your backend URL before sending voice messages."} {responsePreview ? ( Server response {responsePreview} ) : null} ); } const styles = StyleSheet.create({ safeArea: { flex: 1, backgroundColor: "#f4efe4", }, keyboardAvoidingView: { flex: 1, }, scrollView: { flex: 1, }, content: { paddingHorizontal: 20, paddingBottom: 32, paddingTop: 8, gap: 18, }, hero: { backgroundColor: "#13304a", borderRadius: 28, paddingHorizontal: 22, paddingVertical: 24, gap: 12, }, heroTopRow: { alignItems: "center", flexDirection: "row", justifyContent: "space-between", }, heroBadge: { alignSelf: "flex-start", backgroundColor: "#f2b15d", borderRadius: 999, paddingHorizontal: 12, paddingVertical: 6, }, heroBadgeText: { color: "#13304a", fontSize: 12, fontWeight: "700", letterSpacing: 0.5, textTransform: "uppercase", }, settingsLink: { borderColor: "#58718d", borderRadius: 999, borderWidth: 1, paddingHorizontal: 12, paddingVertical: 7, }, settingsLinkText: { color: "#d3deea", fontSize: 13, fontWeight: "700", }, subtitle: { color: "#d3deea", fontSize: 16, lineHeight: 22, }, panel: { backgroundColor: "#fffaf1", borderColor: "#dccfb9", borderRadius: 24, borderWidth: 1, gap: 12, padding: 18, }, meterValueCentered: { color: "#d04f2d", fontSize: 40, fontWeight: "800", textAlign: "center", }, micButton: { alignItems: "center", borderRadius: 999, height: 164, justifyContent: "center", marginVertical: 6, width: 164, alignSelf: "center", }, recordButton: { backgroundColor: "#d04f2d", }, stopButton: { backgroundColor: "#8c1c13", }, micButtonText: { color: "#fff6f3", fontSize: 24, fontWeight: "800", }, buttonDisabled: { opacity: 0.45, }, statusText: { color: "#1f2d3d", fontSize: 15, lineHeight: 21, }, helperText: { color: "#665f54", fontSize: 13, lineHeight: 18, }, responseBox: { backgroundColor: "#f7f0e0", borderRadius: 16, gap: 6, marginTop: 4, padding: 14, }, responseLabel: { color: "#13304a", fontSize: 13, fontWeight: "700", textTransform: "uppercase", }, responseText: { color: "#36475a", fontSize: 14, lineHeight: 20, }, });