import { voicesForLocale } from "@rakazo/core"; import { useFocusEffect } from "expo-router"; import { useCallback, useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { NativeSymbol } from "../components/native-symbol"; import { rpc } from "../lib/api"; import { mobileTokens } from "../lib/appearance"; import { useI18n } from "../lib/i18n"; import { native, useThemedStyles } from "../lib/native"; import { alignVoiceWithLocale, KOKORO_LOCAL_TOKEN, LOCAL_VOICE_PROVIDER, speakText, } from "../lib/voice"; type VoiceCredential = { id: string; provider: string; voiceId: string; }; type VoiceInfo = { id: string; label: string; description?: string }; export default function VoiceSettings() { const styles = useThemedStyles(createVoiceStyles); const { locale, t } = useI18n(); const [connected, setConnected] = useState(false); const [voices, setVoices] = useState([]); const [voiceId, setVoiceId] = useState(""); const [loading, setLoading] = useState(true); const [pending, setPending] = useState<"connect" | "disconnect" | "voice" | null>(null); const [playing, setPlaying] = useState(null); const [error, setError] = useState(null); const load = useCallback(async () => { await alignVoiceWithLocale(locale).catch(() => undefined); const credentials = await rpc("voice/credentials"); const cred = credentials.find((entry) => entry.provider === LOCAL_VOICE_PROVIDER); setConnected(Boolean(cred)); setVoiceId(cred?.voiceId ?? ""); if (cred) { const listed = await rpc("voice/voices", { provider: LOCAL_VOICE_PROVIDER }); setVoices(voicesForLocale(listed, locale)); } else { setVoices([]); } }, [locale]); 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]), ); async function connect() { setPending("connect"); setError(null); try { await rpc("voice/connect", { provider: LOCAL_VOICE_PROVIDER, apiKey: KOKORO_LOCAL_TOKEN }); await load(); } catch (err) { setError(err instanceof Error ? err.message : t("Could not connect")); } finally { setPending(null); } } async function disconnect() { setPending("disconnect"); setError(null); try { await rpc("voice/disconnect", { provider: LOCAL_VOICE_PROVIDER }); await load(); } catch (err) { setError(err instanceof Error ? err.message : t("Could not disconnect")); } finally { setPending(null); } } async function chooseVoice(nextVoiceId: string) { setVoiceId(nextVoiceId); setPending("voice"); setError(null); try { await rpc("voice/setVoice", { voiceId: nextVoiceId, provider: LOCAL_VOICE_PROVIDER }); } catch (err) { setError(err instanceof Error ? err.message : t("Could not save that voice")); await load().catch(() => undefined); } finally { setPending(null); } } async function playVoice(voice: VoiceInfo) { setPlaying(voice.id); setError(null); try { await speakText(t("Hi, this is how I'll sound when I read replies out loud."), { voiceId: voice.id, }); } catch (err) { setError(err instanceof Error ? err.message : t("Could not play a sample")); } finally { setPlaying((current) => (current === voice.id ? null : current)); } } const busy = pending !== null; return ( {loading ? : null} {error ? {error} : null} {!loading && !connected ? ( void connect()} style={[styles.button, busy && styles.disabled]} > {t("Connect")} ) : null} {voices.length ? ( {voices.map((voice) => { const active = voiceId === voice.id; return ( void chooseVoice(voice.id)} style={[styles.card, active && styles.cardActive]} > {voice.label} void playVoice(voice)} style={styles.play} > {playing === voice.id ? ( ) : ( )} {voice.description ? ( {voice.description} ) : null} ); })} ) : null} {connected ? ( void disconnect()} style={[styles.secondary, busy && styles.disabled]} > {pending === "disconnect" ? t("Disconnecting…") : t("Disconnect")} ) : null} ); } const CARD_WIDTH = 160; const CARD_GAP = 12; function createVoiceStyles() { const tokens = mobileTokens(); return StyleSheet.create({ screen: { flex: 1, backgroundColor: native.page }, content: { paddingVertical: 20, gap: 10 }, error: { color: tokens.destructive, marginBottom: 8, marginHorizontal: 20 }, carousel: { flexGrow: 0 }, carouselContent: { paddingHorizontal: 20, gap: CARD_GAP }, card: { width: CARD_WIDTH, borderRadius: 14, borderWidth: 1, borderColor: tokens.border, paddingHorizontal: 14, paddingVertical: 12, backgroundColor: tokens.card, }, cardActive: { borderColor: tokens.primary, borderWidth: 2, backgroundColor: tokens.muted }, cardHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, cardTitle: { flexShrink: 1, color: native.label, fontSize: 16, fontWeight: "600" }, cardMeta: { color: native.tertiaryLabel, marginTop: 4, fontSize: 13 }, play: { width: 30, height: 30, borderRadius: 15, alignItems: "center", justifyContent: "center", backgroundColor: tokens.muted, }, button: { marginHorizontal: 20, backgroundColor: tokens.primary, borderRadius: 12, paddingVertical: 12, alignItems: "center", }, disabled: { opacity: 0.4 }, buttonLabel: { color: tokens.primaryForeground, fontWeight: "600" }, secondary: { marginTop: 16, alignItems: "center" }, secondaryLabel: { color: native.secondaryLabel, fontSize: 15 }, }); }