530 lines
15 KiB
TypeScript
530 lines
15 KiB
TypeScript
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<Locale>("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<number>(0);
|
|
const timerRef = useRef<ReturnType<typeof setInterval> | 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<string, string> = {
|
|
"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 (
|
|
<View style={styles.safeArea}>
|
|
<KeyboardAvoidingView
|
|
style={styles.keyboardAvoidingView}
|
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
|
>
|
|
<ScrollView
|
|
style={styles.scrollView}
|
|
contentContainerStyle={styles.content}
|
|
keyboardShouldPersistTaps="handled"
|
|
>
|
|
<View style={styles.hero}>
|
|
<View style={styles.heroTopRow}>
|
|
<View style={styles.heroBadge}>
|
|
<Text style={styles.heroBadgeText}>{appTitleLabel}</Text>
|
|
</View>
|
|
<Pressable
|
|
onPress={() => router.push("/settings")}
|
|
hitSlop={10}
|
|
style={styles.settingsCog}
|
|
>
|
|
<Svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
<Path
|
|
d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
|
|
fill="#d3deea"
|
|
/>
|
|
<Path
|
|
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1.08-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l-.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1.08 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1.08 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.26.604.852.997 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1.08Z"
|
|
stroke="#d3deea"
|
|
strokeWidth="1.5"
|
|
/>
|
|
</Svg>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.panel}>
|
|
<Text style={[styles.meterValueCentered, isHolding && { color: "#d04f2d" }]}>
|
|
{formatDuration(listeningMs)}
|
|
</Text>
|
|
|
|
<Pressable
|
|
disabled={isSending}
|
|
onPressIn={handlePressIn}
|
|
onPressOut={handlePressOut}
|
|
style={[
|
|
styles.micButton,
|
|
isHolding ? styles.holdingButton : styles.idleButton,
|
|
isSending && styles.buttonDisabled,
|
|
]}
|
|
>
|
|
{isSending ? (
|
|
<ActivityIndicator color="#fff6f3" size="large" />
|
|
) : (
|
|
<Svg width="64" height="64" viewBox="0 0 24 24" fill="none">
|
|
<Path
|
|
d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z"
|
|
fill="#fff6f3"
|
|
stroke="#fff6f3"
|
|
strokeWidth="1"
|
|
/>
|
|
<Path
|
|
d="M19 10v1a7 7 0 0 1-14 0v-1"
|
|
stroke="#fff6f3"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
/>
|
|
<Path
|
|
d="M12 18v3"
|
|
stroke="#fff6f3"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
/>
|
|
</Svg>
|
|
)}
|
|
</Pressable>
|
|
|
|
<Text style={styles.statusText}>
|
|
{statusMessage || strings.readyToRecord}
|
|
</Text>
|
|
<Text style={styles.helperText}>
|
|
{isHolding
|
|
? releaseLabel
|
|
: backendUrl.trim()
|
|
? holdLabel
|
|
: openSettingsHint}
|
|
</Text>
|
|
|
|
{displayText && (
|
|
<View style={styles.transcriptBox}>
|
|
<Text style={styles.transcriptLabel}>{strings.serverResponse}</Text>
|
|
<Text style={styles.transcriptText}>{displayText}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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,
|
|
},
|
|
});
|