Files
quibot/apk/app/index.tsx
2026-06-18 14:09:54 +02:00

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,
},
});