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 [isUploading, setIsUploading] = useState(false); const [isHolding, setIsHolding] = useState(false); const recordingRef = 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]); async function startRecording() { try { setResponsePreview(""); 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; } 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) { 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(); setResponsePreview(responseText.slice(0, 400)); 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 : "", ); } } else { setStatusMessage(strings.noBackendUrl); } } 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); 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(uploadUrl, { method: "POST", headers, body: formData, }); const responseText = await response.text(); setResponsePreview(responseText.slice(0, 400)); if (!response.ok) { throw new Error(`${response.status}. ${responseText}`); } setStatusMessage(strings.uploadComplete); } catch (error) { setStatusMessage(strings.uploadFailed); Alert.alert( strings.uploadFailed, error instanceof Error ? error.message : "", ); } finally { setIsUploading(false); } } 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} {responsePreview ? ( {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, }, });