/** * SettingsScreen - Einstellungen und Verbindungsverwaltung * * QR-Scanner fuer Pairing, Moduswahl, GPS-Toggle, Log-Viewer. */ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { View, Text, TextInput, TouchableOpacity, ScrollView, Switch, StyleSheet, Alert, Platform, ToastAndroid, ActivityIndicator, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import RNFS from 'react-native-fs'; import DocumentPicker from 'react-native-document-picker'; import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs'; import { TTS_PREROLL_DEFAULT_SEC, TTS_PREROLL_MIN_SEC, TTS_PREROLL_MAX_SEC, TTS_PREROLL_STORAGE_KEY, VAD_SILENCE_DEFAULT_SEC, VAD_SILENCE_MIN_SEC, VAD_SILENCE_MAX_SEC, VAD_SILENCE_STORAGE_KEY, CONV_WINDOW_DEFAULT_SEC, CONV_WINDOW_MIN_SEC, CONV_WINDOW_MAX_SEC, CONV_WINDOW_STORAGE_KEY, MAX_RECORDING_DEFAULT_SEC, MAX_RECORDING_MIN_SEC, MAX_RECORDING_MAX_SEC, MAX_RECORDING_STORAGE_KEY, TTS_SPEED_DEFAULT, TTS_SPEED_MIN, TTS_SPEED_MAX, TTS_SPEED_STORAGE_KEY, } from '../services/audio'; import { isWakeReadySoundEnabled, setWakeReadySoundEnabled, playWakeReadySound, } from '../services/wakeReadySound'; import wakeWordService, { WAKE_KEYWORDS, KEYWORD_LABELS, DEFAULT_KEYWORD, WAKE_KEYWORD_STORAGE, } from '../services/wakeword'; import ModeSelector from '../components/ModeSelector'; import QRScanner from '../components/QRScanner'; import VoiceCloneModal from '../components/VoiceCloneModal'; const STORAGE_PATH_KEY = 'aria_attachment_storage_path'; const DEFAULT_STORAGE_PATH = `${RNFS.DocumentDirectoryPath}/chat_attachments`; // --- Typen --- interface LogEntry { id: string; timestamp: number; source: string; message: string; level: 'info' | 'warn' | 'error'; } interface EventEntry { id: string; timestamp: number; title: string; description: string; } type LogTab = 'live' | 'events'; // Settings-Sub-Screens. Reihenfolge im Hauptmenue. const SETTINGS_SECTIONS = [ { id: 'connection', icon: '🔌', label: 'Verbindung', desc: 'Server, Token, Status, Verbindungslog' }, { id: 'general', icon: 'âš™ī¸', label: 'Allgemein', desc: 'Betriebsmodus, GPS-Standort' }, { id: 'voice_input', icon: 'đŸŽ™ī¸', label: 'Spracheingabe', desc: 'Stille-Toleranz, Aufnahmedauer' }, { id: 'wake_word', icon: '👂', label: 'Wake-Word', desc: 'Wake-Word-Auswahl' }, { id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' }, { id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' }, { id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' }, { id: 'about', icon: 'â„šī¸', label: 'Ueber', desc: 'App-Version, Update' }, ] as const; // Container-Farben fuer Live-Logs const SOURCE_COLORS: Record = { 'aria-core': '#4A9EFF', // Blau bridge: '#FFD60A', // Gelb proxy: '#FFFFFF', // Weiss rvs: '#34C759', // Gruen default: '#8888AA', // Grau }; // --- Komponente --- const SettingsScreen: React.FC = () => { const [connectionState, setConnectionState] = useState('disconnected'); const [manualToken, setManualToken] = useState(''); const [manualHost, setManualHost] = useState(''); const [manualPort, setManualPort] = useState('8765'); const [currentMode, setCurrentMode] = useState('normal'); const [gpsEnabled, setGpsEnabled] = useState(false); const [scannerVisible, setScannerVisible] = useState(false); const [logTab, setLogTab] = useState('live'); const [logs, setLogs] = useState([]); const [events, setEvents] = useState([]); const [connLog, setConnLog] = useState(rvs.getConnectionLog()); const [storagePath, setStoragePath] = useState(DEFAULT_STORAGE_PATH); const [autoDownload, setAutoDownload] = useState(true); const [storageSize, setStorageSize] = useState('...'); const [ttsEnabled, setTtsEnabled] = useState(true); const [ttsPrerollSec, setTtsPrerollSec] = useState(TTS_PREROLL_DEFAULT_SEC); const [vadSilenceSec, setVadSilenceSec] = useState(VAD_SILENCE_DEFAULT_SEC); const [convWindowSec, setConvWindowSec] = useState(CONV_WINDOW_DEFAULT_SEC); const [maxRecordingSec, setMaxRecordingSec] = useState(MAX_RECORDING_DEFAULT_SEC); const [ttsSpeed, setTtsSpeed] = useState(TTS_SPEED_DEFAULT); const [wakeKeyword, setWakeKeyword] = useState(DEFAULT_KEYWORD); const [wakeStatus, setWakeStatus] = useState(''); const [wakeReadySound, setWakeReadySound] = useState(true); const [editingPath, setEditingPath] = useState(false); const [xttsVoice, setXttsVoice] = useState(''); const [loadingVoice, setLoadingVoice] = useState(null); const [availableVoices, setAvailableVoices] = useState>([]); const [voiceCloneVisible, setVoiceCloneVisible] = useState(false); const [tempPath, setTempPath] = useState(''); // Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs. // So bleibt aller geteilte State im selben Component-Closure und wir // brauchen keine react-navigation-Stack-Setup. const [currentSection, setCurrentSection] = useState(null); let logIdCounter = 0; // Gespeicherte Config in die Felder laden useEffect(() => { const config = rvs.getConfig(); if (config) { setManualHost(config.host); setManualPort(String(config.port)); setManualToken(config.token); } // Speicherpfad + Auto-Download laden AsyncStorage.getItem(STORAGE_PATH_KEY).then(saved => { if (saved) setStoragePath(saved); }); AsyncStorage.getItem('aria_auto_download').then(saved => { if (saved !== null) setAutoDownload(saved === 'true'); }); AsyncStorage.getItem('aria_tts_enabled').then(saved => { if (saved !== null) setTtsEnabled(saved === 'true'); }); AsyncStorage.getItem('aria_gps_enabled').then(saved => { if (saved !== null) setGpsEnabled(saved === 'true'); }); AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => { if (saved != null) { const n = parseFloat(saved); if (isFinite(n) && n >= TTS_PREROLL_MIN_SEC && n <= TTS_PREROLL_MAX_SEC) { setTtsPrerollSec(n); } } }); AsyncStorage.getItem(VAD_SILENCE_STORAGE_KEY).then(saved => { if (saved != null) { const n = parseFloat(saved); if (isFinite(n) && n >= VAD_SILENCE_MIN_SEC && n <= VAD_SILENCE_MAX_SEC) { setVadSilenceSec(n); } } }); AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY).then(saved => { if (saved != null) { const n = parseFloat(saved); if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) { setConvWindowSec(n); } } }); AsyncStorage.getItem(MAX_RECORDING_STORAGE_KEY).then(saved => { if (saved != null) { const n = parseFloat(saved); if (isFinite(n) && n >= MAX_RECORDING_MIN_SEC && n <= MAX_RECORDING_MAX_SEC) { setMaxRecordingSec(n); } } }); AsyncStorage.getItem(TTS_SPEED_STORAGE_KEY).then(saved => { if (saved != null) { const n = parseFloat(saved); if (isFinite(n) && n >= TTS_SPEED_MIN && n <= TTS_SPEED_MAX) setTtsSpeed(n); } }); AsyncStorage.getItem(WAKE_KEYWORD_STORAGE).then(saved => { if (saved && (WAKE_KEYWORDS as readonly string[]).includes(saved)) setWakeKeyword(saved); }); isWakeReadySoundEnabled().then(setWakeReadySound); AsyncStorage.getItem('aria_xtts_voice').then(saved => { if (saved) setXttsVoice(saved); }); // Voice-Liste vom XTTS-Server holen (via RVS) rvs.send('xtts_list_voices' as any, {}); }, []); // Speichergroesse berechnen useEffect(() => { const calcSize = async () => { try { const exists = await RNFS.exists(storagePath); if (!exists) { setStorageSize('0 KB'); return; } const items = await RNFS.readDir(storagePath); const totalBytes = items.reduce((sum, f) => sum + (f.size || 0), 0); if (totalBytes > 1024 * 1024) { setStorageSize(`${(totalBytes / 1024 / 1024).toFixed(1)} MB (${items.length} Dateien)`); } else { setStorageSize(`${Math.round(totalBytes / 1024)} KB (${items.length} Dateien)`); } } catch { setStorageSize('nicht verfuegbar'); } }; calcSize(); }, [storagePath]); const saveStoragePath = useCallback(async (newPath: string) => { const clean = newPath.trim(); if (!clean) return; await AsyncStorage.setItem(STORAGE_PATH_KEY, clean); setStoragePath(clean); setEditingPath(false); Alert.alert('Gespeichert', `Neuer Speicherort:\n${clean}\n\nWird ab der naechsten Nachricht verwendet.`); }, []); const showPathPicker = useCallback(() => { Alert.alert( 'Speicherort waehlen', 'Wo sollen Anhaenge gespeichert werden?', [ { text: 'Ordner auswaehlen...', onPress: async () => { try { const result = await DocumentPicker.pickDirectory(); if (result?.uri) { // SAF URI decodieren (content://com.android.externalstorage...) const decoded = decodeURIComponent(result.uri); // Versuche einen lesbaren Pfad zu extrahieren const match = decoded.match(/primary[:%]3A(.+)/); const readablePath = match ? `/storage/emulated/0/${match[1].replace(/%2F|%3A/g, '/')}` : decoded; saveStoragePath(readablePath); } } catch (e: any) { if (!DocumentPicker.isCancel(e)) { Alert.alert('Fehler', 'Ordnerauswahl fehlgeschlagen'); } } }, }, { text: 'App-intern (Standard)', onPress: () => saveStoragePath(DEFAULT_STORAGE_PATH), }, { text: 'Pfad manuell eingeben', onPress: () => { setTempPath(storagePath); setEditingPath(true); }, }, { text: 'Abbrechen', style: 'cancel' as const }, ], ); }, [storagePath]); const clearStorageCache = useCallback(async () => { Alert.alert( 'Cache loeschen', `Alle lokalen Anhaenge in\n${storagePath}\nloeschen?\n\nDateien koennen ueber RVS erneut heruntergeladen werden.`, [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Loeschen', style: 'destructive', onPress: async () => { try { const exists = await RNFS.exists(storagePath); if (exists) await RNFS.unlink(storagePath); await RNFS.mkdir(storagePath); setStorageSize('0 KB (0 Dateien)'); Alert.alert('Erledigt', 'Cache geleert. Anhaenge werden bei Bedarf neu geladen.'); } catch (e: any) { Alert.alert('Fehler', e.message); } }, }, ], ); }, [storagePath]); // RVS-Nachrichten und Verbindungslog abonnieren useEffect(() => { const unsubState = rvs.onStateChange(setConnectionState); setConnectionState(rvs.getState()); const unsubLog = rvs.onLog((entry) => { setConnLog(prev => [...prev.slice(-99), entry]); }); const unsubMessage = rvs.onMessage((message: RVSMessage) => { if (message.type === 'log') { const entry: LogEntry = { id: `log_${Date.now()}_${logIdCounter++}`, timestamp: message.timestamp, source: (message.payload.source as string) || 'default', message: (message.payload.message as string) || '', level: (message.payload.level as 'info' | 'warn' | 'error') || 'info', }; setLogs(prev => [...prev.slice(-200), entry]); // Max 200 Eintraege behalten } if (message.type === 'event') { const entry: EventEntry = { id: `evt_${Date.now()}_${logIdCounter++}`, timestamp: message.timestamp, title: (message.payload.title as string) || '', description: (message.payload.description as string) || '', }; setEvents(prev => [...prev.slice(-100), entry]); } // Modus-Bestaetigung if (message.type === 'mode') { const mode = message.payload.mode as string; if (mode) setCurrentMode(mode); } // XTTS-Voice-Liste if (message.type === ('xtts_voices_list' as any)) { const voices = ((message.payload as any).voices || []) as Array<{name: string, size: number}>; setAvailableVoices(voices); } // Voice wurde gespeichert → Liste neu laden + ggf. auswaehlen if (message.type === ('xtts_voice_saved' as any)) { const name = (message.payload as any).name as string; if (name) { setXttsVoice(name); AsyncStorage.setItem('aria_xtts_voice', name); } rvs.send('xtts_list_voices' as any, {}); } // Diagnostic-Voice-Wechsel → lokale App-Stimme auf den neuen Default zuruecksetzen. // Zusaetzlich Preload triggern, damit der User weiss wann's geladen ist. if (message.type === ('config' as any)) { const newVoice = ((message.payload as any).xttsVoice as string) ?? ''; setXttsVoice(newVoice); AsyncStorage.setItem('aria_xtts_voice', newVoice); if (newVoice) { setLoadingVoice(newVoice); } } // XTTS-Bridge meldet: Stimme fertig geladen if (message.type === ('voice_ready' as any)) { const v = ((message.payload as any).voice as string) ?? ''; const err = (message.payload as any).error as string | undefined; const ms = (message.payload as any).loadMs as number | undefined; setLoadingVoice(null); if (err) { ToastAndroid.show(`Stimme "${v}" konnte nicht geladen werden: ${err}`, ToastAndroid.LONG); } else { const suffix = ms ? ` (${(ms / 1000).toFixed(1)}s)` : ''; ToastAndroid.show(`Stimme "${v || 'Standard'}" bereit${suffix}`, ToastAndroid.SHORT); } } }); return () => { unsubState(); unsubMessage(); unsubLog(); }; }, []); // --- QR-Code scannen --- const openQRScanner = useCallback(() => { setScannerVisible(true); }, []); const handleQRScan = useCallback((config: ConnectionConfig) => { setScannerVisible(false); // Felder im UI aktualisieren setManualHost(config.host); setManualPort(String(config.port)); setManualToken(config.token); // Direkt verbinden rvs.setConfig(config); rvs.connect(); Alert.alert( 'Pairing erfolgreich', `Verbinde mit ${config.host}:${config.port}...`, ); }, []); // --- Manuelle Verbindung --- const connectManually = useCallback(() => { if (!manualHost.trim() || !manualToken.trim()) { Alert.alert('Fehler', 'Host und Token muessen angegeben werden.'); return; } const config: ConnectionConfig = { host: manualHost.trim(), port: parseInt(manualPort, 10) || 8765, token: manualToken.trim(), useTLS: true, }; rvs.setConfig(config); rvs.connect(); }, [manualHost, manualPort, manualToken]); const disconnectRVS = useCallback(() => { rvs.disconnect(); }, []); // --- GPS Toggle --- const handleGPSToggle = useCallback((value: boolean) => { setGpsEnabled(value); AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {}); }, []); // --- XTTS Voice --- const selectVoice = useCallback((voiceName: string) => { setXttsVoice(voiceName); AsyncStorage.setItem('aria_xtts_voice', voiceName); // Preload nur fuer Custom-Voices — "Standard" braucht keinen Ladevorgang if (voiceName) { setLoadingVoice(voiceName); rvs.send('voice_preload' as any, { voice: voiceName, source: 'app' }); } else { setLoadingVoice(null); } }, []); const deleteVoice = useCallback((name: string) => { Alert.alert( 'Stimme loeschen', `Stimme "${name}" vom Server endgueltig loeschen?\nAlle Apps verlieren sie.`, [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Loeschen', style: 'destructive', onPress: () => { rvs.send('xtts_delete_voice' as any, { name }); if (xttsVoice === name) { setXttsVoice(''); AsyncStorage.setItem('aria_xtts_voice', ''); } // Liste nach kurzer Wartezeit neu laden (XTTS-Bridge schickt eh neue Liste) setTimeout(() => rvs.send('xtts_list_voices' as any, {}), 500); }, }, ], ); }, [xttsVoice]); // --- Modus aendern --- const handleModeChange = useCallback((modeId: string) => { setCurrentMode(modeId); }, []); // --- Zeitformat --- const formatTime = (ts: number): string => { return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit', }); }; // --- Verbindungsstatus --- const connectionDotColor = connectionState === 'connected' ? '#34C759' : connectionState === 'connecting' ? '#FFD60A' : '#FF3B30'; const connectionLabel = connectionState === 'connected' ? 'Verbunden' : connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt'; return ( <> setScannerVisible(false)} /> setVoiceCloneVisible(false)} /> {currentSection === null && ( <> {SETTINGS_SECTIONS.map(s => ( setCurrentSection(s.id)} > {s.icon} {s.label} {s.desc} â€ē ))} )} {currentSection !== null && ( setCurrentSection(null)} > ‹ {SETTINGS_SECTIONS.find(s => s.id === currentSection)?.label || ''} )} {/* === Verbindung === */} {currentSection === 'connection' && (<> Verbindung {/* Status-Anzeige */} {connectionLabel} {connectionState === 'connected' && ( Trennen )} {/* QR-Scanner */} {'\uD83D\uDCF1'} QR-Code scannen (Pairing) {/* Manuelle Eingabe */} Host Port Token Verbinden {/* === Verbindungslog === */} Verbindungslog { // Auto-Scroll nach unten bei neuen Eintraegen if (ref && connLog.length > 0) { setTimeout(() => ref.scrollToEnd({ animated: false }), 50); } }} > {connLog.length > 0 ? ( connLog.slice(-50).map((entry, idx) => ( {formatTime(entry.timestamp)} {entry.message} )) ) : ( Noch keine Verbindungsversuche )} setConnLog([])} > Log l{'\u00F6'}schen )} {/* === Modus === */} {currentSection === 'general' && (<> Betriebsmodus {/* === GPS === */} Standort GPS-Position mitsenden Position (lat/lon) wird mit jeder Nachricht an ARIA mitgeschickt. Sie sieht's nur intern und nutzt es bei standortbezogenen Fragen ("wo bin ich?", "Wetter hier?"), erwaehnt es sonst nicht. Im Chat-Verlauf bleibt die Bubble unveraendert — nur ARIAs Antwort kann darauf eingehen. )} {/* === Spracheingabe (geraetelokal) === */} {currentSection === 'voice_input' && (<> Spracheingabe Stille-Toleranz Wie lange du eine Sprechpause machen darfst, bevor die Aufnahme automatisch beendet und gesendet wird. Hoeher = mehr Zeit zum Nachdenken; niedriger = schnelleres Senden. Default: {VAD_SILENCE_DEFAULT_SEC.toFixed(1)}s. { const next = Math.max(VAD_SILENCE_MIN_SEC, Math.round((vadSilenceSec - 0.5) * 10) / 10); setVadSilenceSec(next); AsyncStorage.setItem(VAD_SILENCE_STORAGE_KEY, String(next)); }} disabled={vadSilenceSec <= VAD_SILENCE_MIN_SEC} > −0.5 {vadSilenceSec.toFixed(1)} s { const next = Math.min(VAD_SILENCE_MAX_SEC, Math.round((vadSilenceSec + 0.5) * 10) / 10); setVadSilenceSec(next); AsyncStorage.setItem(VAD_SILENCE_STORAGE_KEY, String(next)); }} disabled={vadSilenceSec >= VAD_SILENCE_MAX_SEC} > +0.5 Konversations-Fenster Im Gespraechsmodus (Ohr-Button): nach ARIA's Antwort hast du so lange Zeit, weiter zu sprechen, bevor die Konversation automatisch beendet wird. Sprichst du nichts → Mikrofon zu. Default: {CONV_WINDOW_DEFAULT_SEC.toFixed(1)}s. { const next = Math.max(CONV_WINDOW_MIN_SEC, Math.round((convWindowSec - 1) * 10) / 10); setConvWindowSec(next); AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next)); }} disabled={convWindowSec <= CONV_WINDOW_MIN_SEC} > −1 {convWindowSec.toFixed(0)} s { const next = Math.min(CONV_WINDOW_MAX_SEC, Math.round((convWindowSec + 1) * 10) / 10); setConvWindowSec(next); AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next)); }} disabled={convWindowSec >= CONV_WINDOW_MAX_SEC} > +1 Maximale Aufnahmedauer Notbremse: nach so vielen Minuten wird die Aufnahme automatisch beendet, auch wenn keine Stille erkannt wurde. Nuetzlich fuer lange Erklaerungen oder Diktate. Default: {Math.round(MAX_RECORDING_DEFAULT_SEC / 60)} Min, max {Math.round(MAX_RECORDING_MAX_SEC / 60)} Min. { const next = Math.max(MAX_RECORDING_MIN_SEC, maxRecordingSec - 60); setMaxRecordingSec(next); AsyncStorage.setItem(MAX_RECORDING_STORAGE_KEY, String(next)); }} disabled={maxRecordingSec <= MAX_RECORDING_MIN_SEC} > −1m {Math.round(maxRecordingSec / 60)} min { const next = Math.min(MAX_RECORDING_MAX_SEC, maxRecordingSec + 60); setMaxRecordingSec(next); AsyncStorage.setItem(MAX_RECORDING_STORAGE_KEY, String(next)); }} disabled={maxRecordingSec >= MAX_RECORDING_MAX_SEC} > +1m )} {/* === Wake-Word (komplett on-device, openWakeWord) === */} {currentSection === 'wake_word' && (<> Wake-Word Lokale Erkennung via openWakeWord (ONNX, on-device). Kein API-Key, kein Cloud-Roundtrip — Audio verlaesst das Geraet nicht. Wenn das Ohr aktiv ist, hoerst du normal mit; sagst du das Wake-Word, startet eine Konversation mit ARIA. Wake-Word Eigene Wake-Words via openWakeWord-Notebook trainierbar (gratis). Custom-Upload ueber Diagnostic kommt in einer spaeteren Version. {WAKE_KEYWORDS.map(kw => ( setWakeKeyword(kw)} > {KEYWORD_LABELS[kw]} ))} { setWakeStatus('Initialisiere...'); try { const ok = await wakeWordService.configure(wakeKeyword); setWakeStatus(ok ? `✅ "${KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS]}" bereit` : '❌ Init-Fehler — Logs pruefen'); } catch (err: any) { setWakeStatus('❌ ' + String(err?.message || err).slice(0, 80)); } setTimeout(() => setWakeStatus(''), 5000); }} > Speichern + Aktivieren {!!wakeStatus && ( {wakeStatus} )} Bereit-Sound abspielen Kurzer Ding-Dong wenn das Mikro nach Wake-Word offen ist — akustische Bestaetigung dass du jetzt sprechen darfst. { setWakeReadySound(val); await setWakeReadySoundEnabled(val); if (val) { // Direkt eine Vorschau abspielen damit der User weiss wie's klingt. // playWakeReadySound checked das gerade gesetzte Flag — wenn val=true, // wird abgespielt; bei false bleibt es still. setTimeout(() => playWakeReadySound().catch(() => {}), 150); } }} trackColor={{ false: '#2A2A3E', true: '#0096FF' }} thumbColor={wakeReadySound ? '#FFFFFF' : '#666680'} /> )} {/* === Sprachausgabe (geraetelokal) === */} {currentSection === 'voice_output' && (<> Sprachausgabe Sprachausgabe auf diesem Geraet Nur lokal — andere Geraete sind unabhaengig. Wenn aus, erscheint im Chat auch kein Mund-Button. { setTtsEnabled(val); AsyncStorage.setItem('aria_tts_enabled', String(val)); }} trackColor={{ false: '#2A2A3E', true: '#0096FF' }} thumbColor={ttsEnabled ? '#FFFFFF' : '#666680'} /> {ttsEnabled && ( Puffer vor Wiedergabestart Wie viel Audio gesammelt wird bevor die Wiedergabe startet. Hoeher = robuster gegen Render-Pausen, aber mehr Startverzoegerung. Default: {TTS_PREROLL_DEFAULT_SEC.toFixed(1)}s. { const next = Math.max(TTS_PREROLL_MIN_SEC, Math.round((ttsPrerollSec - 0.5) * 10) / 10); setTtsPrerollSec(next); AsyncStorage.setItem(TTS_PREROLL_STORAGE_KEY, String(next)); }} disabled={ttsPrerollSec <= TTS_PREROLL_MIN_SEC} > −0.5 {ttsPrerollSec.toFixed(1)} s { const next = Math.min(TTS_PREROLL_MAX_SEC, Math.round((ttsPrerollSec + 0.5) * 10) / 10); setTtsPrerollSec(next); AsyncStorage.setItem(TTS_PREROLL_STORAGE_KEY, String(next)); }} disabled={ttsPrerollSec >= TTS_PREROLL_MAX_SEC} > +0.5 Sprechgeschwindigkeit Wie schnell ARIA spricht. 1.0 = Normal. Niedriger = langsamer, hoeher = schneller. Wird an F5-TTS als speed-Param uebergeben und pro Geraet gespeichert. Default: {TTS_SPEED_DEFAULT.toFixed(1)}x. { const next = Math.max(TTS_SPEED_MIN, Math.round((ttsSpeed - 0.1) * 10) / 10); setTtsSpeed(next); AsyncStorage.setItem(TTS_SPEED_STORAGE_KEY, String(next)); }} disabled={ttsSpeed <= TTS_SPEED_MIN} > −0.1 {ttsSpeed.toFixed(1)} x { const next = Math.min(TTS_SPEED_MAX, Math.round((ttsSpeed + 0.1) * 10) / 10); setTtsSpeed(next); AsyncStorage.setItem(TTS_SPEED_STORAGE_KEY, String(next)); }} disabled={ttsSpeed >= TTS_SPEED_MAX} > +0.1 )} {ttsEnabled && ( Stimme (geraetelokal) Eine geklonte Stimme auswaehlen. F5-TTS braucht zwingend eine Referenz — ohne Auswahl gilt die in Diagnostic gewaehlte globale Stimme. {availableVoices.length === 0 ? ( Keine geklonten Stimmen vorhanden — unten "Eigene Stimme aufnehmen". ) : ( availableVoices.map(v => ( selectVoice(v.name)} > {v.name} {(v.size / 1024).toFixed(0)} KB {loadingVoice === v.name && ( )} {xttsVoice === v.name && loadingVoice !== v.name && {'\u2713'}} deleteVoice(v.name)} style={styles.voiceRowDelete}> X )) )} setVoiceCloneVisible(true)} > {'\uD83C\uDFA4'} Eigene Stimme aufnehmen rvs.send('xtts_list_voices' as any, {})} > Aktualisieren )} )} {/* === Speicher === */} {currentSection === 'storage' && (<> Anhang-Speicher Auto-Download Fehlende Anhaenge beim App-Start automatisch vom Server laden { setAutoDownload(val); AsyncStorage.setItem('aria_auto_download', String(val)); }} trackColor={{ false: '#2A2A3E', true: '#0096FF' }} thumbColor={autoDownload ? '#FFFFFF' : '#666680'} /> Lokaler Speicherort Hier werden Bilder und Dateien aus dem Chat gespeichert. {autoDownload ? ' Fehlende Dateien werden automatisch nachgeladen.' : ' Fehlende Dateien koennen per Tippen geladen werden.'} {editingPath ? ( saveStoragePath(tempPath)} > Speichern setEditingPath(false)} > Abbrechen ) : ( {storagePath} {storageSize} Pfad aendern Cache leeren )} )} {/* === Logs === */} {currentSection === 'protocol' && (<> Protokoll {/* Tab-Umschalter */} setLogTab('live')} > Live Logs setLogTab('events')} > Events {/* Log-Inhalt */} {logTab === 'live' ? ( logs.length > 0 ? ( logs.slice(-50).map(log => ( {formatTime(log.timestamp)} [{log.source}] {log.message} )) ) : ( Noch keine Logs empfangen ) ) : ( events.length > 0 ? ( events.slice(-30).map(event => ( {formatTime(event.timestamp)} {event.title} {event.description} )) ) : ( Noch keine Events empfangen ) )} {/* Log-Aktionen */} { if (logTab === 'live') setLogs([]); else setEvents([]); }} > Protokoll l\u00F6schen )} {/* === About === */} {currentSection === 'about' && (<> {'\u00DC'}ber ARIA Cockpit Version {require('../../package.json').version} Stefans Kommandozentrale f{'\u00FC'}r ARIA.{'\n'} Gebaut mit React Native + TypeScript. { const updateService = require('../services/updater').default; updateService.checkForUpdate(); Alert.alert('Update-Check', 'Pruefe auf neue Version...'); }} > Auf Updates pr{'\u00FC'}fen )} {/* Platz am Ende */} ); }; // --- Styles --- const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#0D0D1A', }, content: { padding: 16, }, sectionTitle: { color: '#8888AA', fontSize: 13, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1, marginTop: 20, marginBottom: 8, marginLeft: 4, }, menuItem: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#1E1E2E', borderRadius: 10, paddingVertical: 14, paddingHorizontal: 14, marginBottom: 8, }, menuItemIcon: { fontSize: 22, marginRight: 14, width: 28, textAlign: 'center', }, menuItemTextWrap: { flex: 1, }, menuItemLabel: { color: '#FFFFFF', fontSize: 16, fontWeight: '600', }, menuItemDesc: { color: '#8888AA', fontSize: 12, marginTop: 2, }, menuItemChevron: { color: '#8888AA', fontSize: 24, fontWeight: '300', marginLeft: 8, }, subScreenHeader: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, marginBottom: 8, }, subScreenBack: { color: '#0096FF', fontSize: 32, fontWeight: '300', marginRight: 12, lineHeight: 36, }, subScreenTitle: { color: '#FFFFFF', fontSize: 20, fontWeight: '700', }, card: { backgroundColor: '#12122A', borderRadius: 14, padding: 16, borderWidth: 1, borderColor: '#1E1E2E', }, // Verbindungsstatus statusRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 16, }, statusDot: { width: 10, height: 10, borderRadius: 5, marginRight: 10, }, statusLabel: { color: '#FFFFFF', fontSize: 16, fontWeight: '600', flex: 1, }, disconnectButton: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, backgroundColor: 'rgba(255, 59, 48, 0.2)', }, disconnectText: { color: '#FF3B30', fontSize: 13, fontWeight: '600', }, // QR-Button qrButton: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#1E1E2E', borderRadius: 10, padding: 14, marginBottom: 16, }, qrIcon: { fontSize: 22, marginRight: 10, }, qrText: { color: '#FFFFFF', fontSize: 15, fontWeight: '500', }, // Eingabefelder inputLabel: { color: '#8888AA', fontSize: 12, marginBottom: 4, marginLeft: 2, }, input: { backgroundColor: '#1E1E2E', borderRadius: 8, paddingHorizontal: 14, paddingVertical: 10, color: '#FFFFFF', fontSize: 15, marginBottom: 12, borderWidth: 1, borderColor: '#2A2A3E', }, connectButton: { backgroundColor: '#0096FF', borderRadius: 10, padding: 14, alignItems: 'center', marginTop: 4, }, connectButtonText: { color: '#FFFFFF', fontSize: 16, fontWeight: '700', }, // Toggle toggleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, toggleInfo: { flex: 1, marginRight: 12, }, toggleLabel: { color: '#FFFFFF', fontSize: 15, fontWeight: '500', }, toggleHint: { color: '#666680', fontSize: 12, marginTop: 2, }, // XTTS Voice List voiceRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#1E1E2E', borderRadius: 8, padding: 10, marginTop: 6, borderWidth: 1, borderColor: 'transparent', }, voiceRowActive: { borderColor: '#0096FF', backgroundColor: '#0D1A2E', }, voiceRowName: { color: '#CCCCDD', fontSize: 14, fontWeight: '500', }, voiceRowNameActive: { color: '#FFFFFF', }, voiceRowMeta: { color: '#666680', fontSize: 11, marginTop: 2, }, voiceRowCheck: { color: '#34C759', fontSize: 16, fontWeight: '700', marginHorizontal: 6, }, voiceRowDelete: { width: 28, height: 28, borderRadius: 14, backgroundColor: 'rgba(255,59,48,0.2)', alignItems: 'center', justifyContent: 'center', marginLeft: 4, }, voiceRowDeleteIcon: { color: '#FF3B30', fontSize: 12, fontWeight: '700', }, // Stimmen voiceBtn: { flex: 1, padding: 12, borderRadius: 10, backgroundColor: '#1E1E2E', alignItems: 'center', borderWidth: 2, borderColor: 'transparent', }, voiceBtnActive: { borderColor: '#0096FF', backgroundColor: '#0D1A2E', }, voiceBtnIcon: { fontSize: 28, marginBottom: 4, }, voiceBtnText: { color: '#8888AA', fontSize: 14, fontWeight: '600', }, voiceBtnTextActive: { color: '#FFFFFF', }, voiceBtnHint: { color: '#555570', fontSize: 11, marginTop: 2, }, // Speicher storagePathText: { color: '#0096FF', fontSize: 12, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', }, storageSizeText: { color: '#8888AA', fontSize: 12, marginTop: 4, }, // Logs tabRow: { flexDirection: 'row', marginBottom: 12, borderRadius: 8, backgroundColor: '#1E1E2E', overflow: 'hidden', }, tab: { flex: 1, paddingVertical: 10, alignItems: 'center', }, tabActive: { backgroundColor: '#0096FF', }, tabText: { color: '#666680', fontSize: 13, fontWeight: '600', }, tabTextActive: { color: '#FFFFFF', }, connLogScroll: { maxHeight: 200, backgroundColor: '#0A0A18', borderRadius: 8, padding: 10, }, logContainer: { maxHeight: 300, backgroundColor: '#0A0A18', borderRadius: 8, padding: 10, }, logEntry: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 4, }, logTime: { color: '#555570', fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', marginRight: 6, }, logSource: { fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', fontWeight: '700', marginRight: 6, }, logMessage: { color: '#CCCCDD', fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', flex: 1, }, logError: { color: '#FF3B30', }, logWarn: { color: '#FFD60A', }, emptyLog: { color: '#555570', fontSize: 13, textAlign: 'center', padding: 20, }, eventEntry: { marginBottom: 10, paddingBottom: 10, borderBottomWidth: 1, borderBottomColor: '#1E1E2E', }, eventTime: { color: '#555570', fontSize: 11, }, eventTitle: { color: '#FFFFFF', fontSize: 14, fontWeight: '600', marginTop: 2, }, eventDescription: { color: '#8888AA', fontSize: 13, marginTop: 2, }, clearButton: { marginTop: 10, padding: 10, alignItems: 'center', borderRadius: 8, backgroundColor: '#1E1E2E', }, clearButtonText: { color: '#666680', fontSize: 13, fontWeight: '500', }, // About aboutTitle: { color: '#FFFFFF', fontSize: 18, fontWeight: '700', }, aboutVersion: { color: '#0096FF', fontSize: 13, marginTop: 2, }, aboutInfo: { color: '#666680', fontSize: 13, marginTop: 8, lineHeight: 20, }, bottomSpacer: { height: 40, }, prerollRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginTop: 12, gap: 16, }, prerollButton: { backgroundColor: '#2A2A3E', paddingHorizontal: 18, paddingVertical: 10, borderRadius: 8, minWidth: 72, alignItems: 'center', }, prerollButtonText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600', }, prerollValue: { color: '#FFFFFF', fontSize: 20, fontWeight: '700', minWidth: 80, textAlign: 'center', }, keywordChip: { backgroundColor: '#1E1E2E', borderWidth: 1, borderColor: '#2A2A3E', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 14, }, keywordChipActive: { backgroundColor: '#0096FF', borderColor: '#0096FF', }, keywordChipText: { color: '#8888AA', fontSize: 13, fontWeight: '500', }, keywordChipTextActive: { color: '#FFFFFF', fontWeight: '700', }, }); export default SettingsScreen;