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 { speakText } from "../lib/voice"; 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 [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(false); 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 : "Could not load voice settings"), ) .finally(() => setLoading(false)); }, [load]), ); const selected = catalog.find((entry) => entry.id === provider); const credential = credentials.find((entry) => entry.provider === provider); async function connect() { if (!selected || apiKey.trim().length < 8) return; setPending(true); setError(null); try { await rpc("voice/connect", { provider: selected.id, apiKey: apiKey.trim(), voiceId: voiceId || undefined, }); setApiKey(""); await load(selected.id); setNotice(`Connected ${selected.name}.`); } catch (err) { setError(err instanceof Error ? err.message : "Could not connect"); } finally { setPending(false); } } async function chooseVoice(nextVoiceId: string) { setVoiceId(nextVoiceId); setPending(true); try { await rpc("voice/setVoice", { voiceId: nextVoiceId, provider: selected?.id }); await load(selected?.id); } catch (err) { setError(err instanceof Error ? err.message : "Could not save that voice"); } finally { setPending(false); } } async function testVoice() { setPending(true); setError(null); try { const ready = await speakText("Hi, this is how I'll sound when I read replies out loud."); if (!ready) { throw new Error("Connect a voice provider first."); } setNotice("If you heard that, voice is ready."); } catch (err) { setError(err instanceof Error ? err.message : "Could not play a sample"); } finally { setPending(false); } } return ( {loading ? : null} {error ? {error} : null} {notice ? {notice} : null} Bring your own key. ElevenLabs, OpenAI, and Cartesia all plug into the same speak buttons. {catalog.map((entry) => { const connected = credentials.some((cred) => cred.provider === entry.id); return ( { setProvider(entry.id); void load(entry.id); }} style={[styles.card, provider === entry.id && styles.cardActive]} > {entry.name} {connected ? "Connected" : entry.transcribe ? "Speak + transcribe" : "Speak only"} ); })} {selected ? ( <> {selected.description} void connect()} style={[styles.button, (pending || apiKey.trim().length < 8) && styles.disabled]} > {credential ? "Replace key" : "Connect"} {voices.length ? ( {voices.map((voice) => ( void chooseVoice(voice.id)} style={styles.voiceRow} > {voice.label} {voiceId === voice.id ? : null} ))} ) : null} {status?.ready ? ( void testVoice()} style={styles.secondary} > Hear a sample ) : null} ) : null} ); } const styles = StyleSheet.create({ screen: { flex: 1, backgroundColor: "#000" }, content: { padding: 20, gap: 10 }, lede: { color: "#85858A", fontSize: 14, lineHeight: 20, marginBottom: 8 }, error: { color: "#C94244", marginBottom: 8 }, notice: { color: "#4ECB71", marginBottom: 8 }, card: { borderRadius: 14, borderWidth: 1, borderColor: "#26262A", padding: 14, backgroundColor: "#101012", }, cardActive: { borderColor: "#4A4A50", backgroundColor: "#1A1A1D" }, cardTitle: { color: "#ECECEE", fontSize: 16 }, cardMeta: { color: "#6C6C70", marginTop: 4, fontSize: 12 }, help: { color: "#85858A", fontSize: 13.5, lineHeight: 20, marginTop: 8 }, input: { marginTop: 8, borderRadius: 12, borderWidth: 1, borderColor: "#26262A", color: "#ECECEE", paddingHorizontal: 14, paddingVertical: 12, }, button: { marginTop: 8, backgroundColor: "#F1F1EF", borderRadius: 12, paddingVertical: 12, alignItems: "center", }, disabled: { opacity: 0.4 }, buttonLabel: { color: "#17171A", fontWeight: "600" }, voices: { marginTop: 12, borderRadius: 12, borderWidth: 1, borderColor: "#26262A" }, voiceRow: { flexDirection: "row", justifyContent: "space-between", paddingHorizontal: 14, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: "#202023", }, voiceLabel: { color: "#ECECEE" }, check: { color: "#4ECB71" }, secondary: { marginTop: 16, alignItems: "center" }, secondaryLabel: { color: "#C9C9CE", fontSize: 15 }, });