import { useFocusEffect } from "expo-router"; import { useCallback, useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { rpc } from "../lib/api"; import { mobileTokens } from "../lib/appearance"; import { useI18n } from "../lib/i18n"; import { native, useThemedStyles } from "../lib/native"; import { speakText } from "../lib/voice"; const KOKORO_LOCAL_TOKEN = "local-kokoro"; type VoiceCatalogEntry = { id: string; name: string; description: string; transcribe: boolean; }; type VoiceCredential = { id: string; provider: string; voiceId: string; }; type VoiceStatus = { configured: boolean; ready: boolean; provider: string | null; voiceId: string; }; type VoiceInfo = { id: string; label: string; description?: string }; export default function VoiceSettings() { const styles = useThemedStyles(createVoiceStyles); const { t } = useI18n(); const [catalog, setCatalog] = useState([]); const [credentials, setCredentials] = useState([]); const [status, setStatus] = useState(null); const [voices, setVoices] = useState([]); const [provider, setProvider] = useState(""); const [apiKey, setApiKey] = useState(""); const [voiceId, setVoiceId] = useState(""); const [loading, setLoading] = useState(true); const [pending, setPending] = useState<"connect" | "disconnect" | "voice" | "test" | null>(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const load = useCallback(async (nextProvider?: string) => { const [nextCatalog, nextCredentials, nextStatus] = await Promise.all([ rpc("voice/catalog"), rpc("voice/credentials"), rpc("voice/status"), ]); const selected = nextProvider || nextStatus.provider || nextCatalog[0]?.id || ""; setCatalog(nextCatalog); setCredentials(nextCredentials); setStatus(nextStatus); setProvider(selected); const cred = nextCredentials.find((entry) => entry.provider === selected); setVoiceId(cred?.voiceId ?? ""); if (cred) { setVoices(await rpc("voice/voices", { provider: selected })); } else { setVoices([]); } }, []); useFocusEffect( useCallback(() => { setLoading(true); void load() .catch((err: unknown) => setError(err instanceof Error ? err.message : t("Could not load voice settings")), ) .finally(() => setLoading(false)); }, [load]), ); const selected = catalog.find((entry) => entry.id === provider); const isKokoro = selected?.id === "kokoro"; const credential = credentials.find((entry) => entry.provider === provider); const canConnect = isKokoro || apiKey.trim().length >= 8; async function connect() { if (!selected || !canConnect) return; setPending("connect"); setError(null); try { await rpc("voice/connect", { provider: selected.id, apiKey: isKokoro ? KOKORO_LOCAL_TOKEN : apiKey.trim(), voiceId: voiceId || undefined, }); setApiKey(""); await load(selected.id); setNotice(t("Connected {name}.", { name: selected.name })); } catch (err) { setError(err instanceof Error ? err.message : t("Could not connect")); } finally { setPending(null); } } async function disconnect() { if (!credential) return; setPending("disconnect"); setError(null); setNotice(null); try { await rpc("voice/disconnect", { provider: credential.provider }); setApiKey(""); await load(credential.provider); } catch (err) { setError(err instanceof Error ? err.message : t("Could not disconnect")); } finally { setPending(null); } } async function chooseVoice(nextVoiceId: string) { setVoiceId(nextVoiceId); setPending("voice"); try { await rpc("voice/setVoice", { voiceId: nextVoiceId, provider: selected?.id }); await load(selected?.id); } catch (err) { setError(err instanceof Error ? err.message : t("Could not save that voice")); } finally { setPending(null); } } async function testVoice() { setPending("test"); setError(null); try { const ready = await speakText(t("Hi, this is how I'll sound when I read replies out loud.")); if (!ready) { throw new Error(t("Connect a voice provider first.")); } } catch (err) { setError(err instanceof Error ? err.message : t("Could not play a sample")); } finally { setPending(null); } } return ( {loading ? : null} {error ? {error} : null} {notice ? {notice} : null} {catalog.map((entry) => { const connected = credentials.some((cred) => cred.provider === entry.id); return ( { setProvider(entry.id); setPending("voice"); void load(entry.id) .catch((err: unknown) => setError( err instanceof Error ? err.message : t("Could not load voice settings"), ), ) .finally(() => setPending(null)); }} style={[ styles.card, provider === entry.id && styles.cardActive, pending !== null && styles.disabled, ]} > {entry.name} {connected ? t("Connected") : entry.transcribe ? t("Speak + transcribe") : t("Speak only")} ); })} {selected ? ( <> {isKokoro ? ( {t("Local voice runs on your server. Tap Connect when it is ready.")} ) : ( )} void connect()} style={[styles.button, (pending !== null || !canConnect) && styles.disabled]} > {credential ? (isKokoro ? t("Reconnect") : t("Replace key")) : t("Connect")} {credential ? ( void disconnect()} style={[styles.secondary, pending !== null && styles.disabled]} > {pending === "disconnect" ? t("Disconnecting…") : t("Disconnect")} ) : null} {voices.length ? ( {voices.map((voice) => ( void chooseVoice(voice.id)} style={[styles.voiceRow, pending !== null && styles.disabled]} > {voice.label} {voiceId === voice.id ? ✓ : null} ))} ) : null} {status?.ready ? ( void testVoice()} style={styles.secondary} > {t("Hear a sample")} ) : null} ) : null} ); } function createVoiceStyles() { const tokens = mobileTokens(); return StyleSheet.create({ screen: { flex: 1, backgroundColor: native.page }, content: { padding: 20, gap: 10 }, error: { color: tokens.destructive, marginBottom: 8 }, notice: { color: tokens.success, marginBottom: 8 }, card: { borderRadius: 14, borderWidth: 1, borderColor: tokens.border, padding: 14, backgroundColor: tokens.card, }, cardActive: { borderColor: tokens.ring, backgroundColor: tokens.muted }, cardTitle: { color: native.label, fontSize: 16 }, cardMeta: { color: native.tertiaryLabel, marginTop: 4, fontSize: 12 }, help: { marginTop: 8, color: native.secondaryLabel, fontSize: 14, lineHeight: 20 }, input: { marginTop: 8, borderRadius: 12, borderWidth: 1, borderColor: tokens.border, color: native.label, paddingHorizontal: 14, paddingVertical: 12, }, button: { marginTop: 8, backgroundColor: tokens.primary, borderRadius: 12, paddingVertical: 12, alignItems: "center", }, disabled: { opacity: 0.4 }, buttonLabel: { color: tokens.primaryForeground, fontWeight: "600" }, voices: { marginTop: 12, borderRadius: 12, borderWidth: 1, borderColor: tokens.border }, voiceRow: { flexDirection: "row", justifyContent: "space-between", paddingHorizontal: 14, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: tokens.border, }, voiceLabel: { color: native.label }, check: { color: tokens.success }, secondary: { marginTop: 16, alignItems: "center" }, secondaryLabel: { color: native.secondaryLabel, fontSize: 15 }, }); }