/** * 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, Modal, PermissionsAndroid, useWindowDimensions, DeviceEventEmitter, NativeModules, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import RNFS from 'react-native-fs'; const { FileOpener } = NativeModules as { FileOpener?: { open: (filePath: string, mimeType: string) => Promise }; }; // MIME-Type aus Dateinamen schaetzen — fuer den FileOpener-Intent. Android // nutzt den MIME-Type um die passende App zu finden. Unknown → octet-stream. function guessMimeFromName(name: string): string { const lower = name.toLowerCase(); if (lower.endsWith('.pdf')) return 'application/pdf'; if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'; if (lower.endsWith('.png')) return 'image/png'; if (lower.endsWith('.gif')) return 'image/gif'; if (lower.endsWith('.webp')) return 'image/webp'; if (lower.endsWith('.mp3')) return 'audio/mpeg'; if (lower.endsWith('.wav')) return 'audio/wav'; if (lower.endsWith('.ogg') || lower.endsWith('.opus')) return 'audio/ogg'; if (lower.endsWith('.mp4') || lower.endsWith('.m4a')) return 'audio/mp4'; if (lower.endsWith('.webm')) return 'video/webm'; if (lower.endsWith('.txt')) return 'text/plain'; if (lower.endsWith('.md')) return 'text/markdown'; if (lower.endsWith('.json')) return 'application/json'; if (lower.endsWith('.csv')) return 'text/csv'; if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'text/html'; if (lower.endsWith('.zip')) return 'application/zip'; return 'application/octet-stream'; } 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, STT_ENDPOINT_DEFAULT_MS, STT_ENDPOINT_MIN_MS, STT_ENDPOINT_MAX_MS, STT_ENDPOINT_STORAGE_KEY, MAX_RECORDING_DEFAULT_SEC, MAX_RECORDING_MIN_SEC, MAX_RECORDING_MAX_SEC, MAX_RECORDING_STORAGE_KEY, loadBargeInEnabled, saveBargeInEnabled, VAD_SILENCE_DB_DEFAULT, VAD_SILENCE_DB_MIN, VAD_SILENCE_DB_MAX, VAD_SILENCE_DB_OVERRIDE_KEY, TTS_SPEED_DEFAULT, TTS_SPEED_MIN, TTS_SPEED_MAX, TTS_SPEED_STORAGE_KEY, } from '../services/audio'; import audioService from '../services/audio'; import gpsTrackingService, { isBackgroundGpsEnabled, setBackgroundGpsEnabled, ensureBackgroundLocationPermission, } from '../services/gpsTracking'; import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio'; import MemoryBrowser from '../components/MemoryBrowser'; import TriggerBrowser from '../components/TriggerBrowser'; import SkillBrowser from '../components/SkillBrowser'; import OAuthBrowser from '../components/OAuthBrowser'; import VoiceIdEnrollment from '../components/VoiceIdEnrollment'; import ProjectsBrowser from '../components/ProjectsBrowser'; import brainApi from '../services/brainApi'; import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger'; import { isWakeReadySoundEnabled, setWakeReadySoundEnabled, playWakeReadySound, } from '../services/wakeReadySound'; import wakeWordService, { WAKE_KEYWORDS, KEYWORD_LABELS, DEFAULT_KEYWORD, WAKE_KEYWORD_STORAGE, WAKE_THRESHOLD_DEFAULT, WAKE_THRESHOLD_MIN, WAKE_THRESHOLD_MAX, loadWakeThreshold, saveWakeThreshold, loadBgWakeEnabled, saveBgWakeEnabled, loadWakeConfirmEnabled, saveWakeConfirmEnabled, } from '../services/wakeword'; import ModeSelector from '../components/ModeSelector'; import QRScanner from '../components/QRScanner'; import VoiceCloneModal from '../components/VoiceCloneModal'; import updateService from '../services/updater'; 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_id', icon: '🎤', label: 'Stimme einrichten', desc: 'Sprecher-Erkennung — nur deine Stimme triggert ARIA' }, { id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' }, { id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' }, { id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' }, { id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' }, { id: 'projects', icon: '📁', label: 'Projekte', desc: 'Thread-Bündel im Hauptchat — verwalten, wechseln, beenden' }, { id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, löschen' }, { id: 'skills', icon: '🛠️', label: 'Skills', desc: 'Skills ausführen, aktivieren, Logs ansehen, löschen' }, { id: 'oauth', icon: '🔑', label: 'OAuth-Apps', desc: 'Spotify, Dropbox, ... — client_id/secret, autorisieren, abmelden' }, { 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 winDims = useWindowDimensions(); const [connectionState, setConnectionState] = useState('disconnected'); const [manualToken, setManualToken] = useState(''); // Abgelaufenes Server-Zertifikat trotzdem akzeptieren (Kette + Hostname werden weiter geprueft) const [allowExpiredCert, setAllowExpiredCert] = useState(false); useEffect(() => { rvs.getAllowExpiredCert().then(setAllowExpiredCert); }, []); const handleAllowExpiredCert = useCallback(async (value: boolean) => { setAllowExpiredCert(value); await rvs.setAllowExpiredCert(value); rvs.connect(true); // gleich mit der neuen Regel neu verbinden }, []); const [manualHost, setManualHost] = useState(''); const [manualPort, setManualPort] = useState('8765'); const [currentMode, setCurrentMode] = useState('normal'); const [gpsEnabled, setGpsEnabled] = useState(false); const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive()); const [bgGpsEnabled, setBgGpsEnabled] = useState(false); const [backgroundMode, setBackgroundMode] = useState(true); // Default an const [showSystemHints, setShowSystemHints] = useState(false); // Default aus const [showSource, setShowSource] = useState(false); // Quell-Badge, Default aus 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); // Aktive Streaming-Pausen-Toleranz (STT_ENDPOINT) — der "Stille-Toleranz"-Regler // steuert jetzt DIESEN Wert (der alte vadSilenceSec war der tote Legacy-dB-Pfad). const [sttEndpointSec, setSttEndpointSec] = useState(STT_ENDPOINT_DEFAULT_MS / 1000); const [maxRecordingSec, setMaxRecordingSec] = useState(MAX_RECORDING_DEFAULT_SEC); // Barge-in: ARIA waehrend ihrer Antwort unterbrechen duerfen. Default aus (Halb-Duplex). const [bargeIn, setBargeIn] = useState(false); // null = automatisch (adaptive Baseline), sonst manueller dB-Override const [vadSilenceDb, setVadSilenceDb] = useState(null); const [showVadInfo, setShowVadInfo] = useState(false); const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null); const [ttsCacheInfo, setTtsCacheInfo] = useState<{count: number, totalMB: number} | null>(null); const [verboseLogging, setVerboseLoggingState] = useState(isVerboseLogging()); const [debugLogsToBridge, setDebugLogsToBridgeState] = useState(isDebugLogsToBridge()); const [ttsSpeed, setTtsSpeed] = useState(TTS_SPEED_DEFAULT); const [wakeKeyword, setWakeKeyword] = useState(DEFAULT_KEYWORD); const [wakeStatus, setWakeStatus] = useState(''); const [wakeReadySound, setWakeReadySound] = useState(true); const [wakeThreshold, setWakeThreshold] = useState(WAKE_THRESHOLD_DEFAULT); // Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus. const [bgWake, setBgWake] = useState(false); // Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger). Default aus. const [wakeConfirm, setWakeConfirm] = useState(false); const [editingPath, setEditingPath] = useState(false); const [xttsVoice, setXttsVoice] = useState(''); const [loadingVoice, setLoadingVoice] = useState(null); const [availableVoices, setAvailableVoices] = useState>([]); // Datei-Manager const [fileManagerOpen, setFileManagerOpen] = useState(false); const [fileManagerFiles, setFileManagerFiles] = useState>([]); const [fileFilterProjectId, setFileFilterProjectId] = useState('__all__'); const [fileFilterProjects, setFileFilterProjects] = useState>([]); const [fileManagerLoading, setFileManagerLoading] = useState(false); const [fileManagerError, setFileManagerError] = useState(''); const [fileManagerSearch, setFileManagerSearch] = useState(''); const [fileManagerFilter, setFileManagerFilter] = useState<'all' | 'aria' | 'user'>('all'); const [fileManagerSelected, setFileManagerSelected] = useState>(new Set()); const fileZipPending = useRef(null); // requestId fuer ZIP-Antwort const [fileZipBusy, setFileZipBusy] = useState(false); // Versions-Modal — pro Datei eine kleine Historie aus dem auto-commit-git // im diagnostic-Container. Browser-Variante davon laeuft schon, hier App- // Side via RVS-Messages (file_version_list_request/...). const [versionsOpen, setVersionsOpen] = useState<{name: string; path: string} | null>(null); const [versionsList, setVersionsList] = useState>([]); const [versionsLoading, setVersionsLoading] = useState(false); const [versionsError, setVersionsError] = useState(''); const versionDlPending = useRef(null); // requestId beim Versions-Download 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('aria_background_mode').then(saved => { // Default ist an — nur explicit 'false' deaktiviert setBackgroundMode(saved !== 'false'); }); AsyncStorage.getItem('aria_show_hints').then(saved => { // Default ist aus — nur explicit 'true' aktiviert setShowSystemHints(saved === 'true'); }); AsyncStorage.getItem('aria_show_source').then(saved => { setShowSource(saved === 'true'); // Default aus }); // gpsTrackingService status syncen + auf Aenderungen lauschen setGpsTracking(gpsTrackingService.isActive()); const offGps = gpsTrackingService.onChange(setGpsTracking); // Persistierten Status wiederherstellen (war Tracking beim letzten Mal an?) gpsTrackingService.restoreFromStorage().catch(() => {}); // Background-GPS-Toggle initial laden isBackgroundGpsEnabled().then(setBgGpsEnabled).catch(() => {}); 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(STT_ENDPOINT_STORAGE_KEY).then(saved => { if (saved != null) { const n = parseInt(saved, 10); if (isFinite(n) && n >= STT_ENDPOINT_MIN_MS && n <= STT_ENDPOINT_MAX_MS) { setSttEndpointSec(n / 1000); } } }); 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); } } }); loadBargeInEnabled().then(setBargeIn).catch(() => {}); AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY).then(saved => { if (saved != null && saved !== '') { const n = parseFloat(saved); if (isFinite(n) && n >= VAD_SILENCE_DB_MIN && n <= VAD_SILENCE_DB_MAX) { setVadSilenceDb(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); loadWakeThreshold().then(setWakeThreshold).catch(() => {}); loadBgWakeEnabled().then(setBgWake).catch(() => {}); loadWakeConfirmEnabled().then(setWakeConfirm).catch(() => {}); updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {}); audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {}); 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, {}); return () => { // gpsTrackingService-Listener abmelden (Variable offGps oben definiert) try { offGps(); } catch {} }; }, []); // 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]); }); // Lokale App-Logs (reportAppDebug/Error) im Live-Logs-Tab anzeigen // — damit Stefan ohne curl direkt in der App sieht was passiert. const localLogSub = DeviceEventEmitter.addListener(APP_LOG_EVENT, (e: any) => { const entry: LogEntry = { id: `applog_${e.ts || Date.now()}_${logIdCounter++}`, timestamp: e.ts || Date.now(), source: e.scope || 'app', message: e.message || '', level: e.level || 'info', }; setLogs(prev => [...prev.slice(-200), 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); } // Datei-Manager: Liste empfangen if (message.type === ('file_list_response' as any)) { const p: any = message.payload || {}; if (p.ok) { setFileManagerFiles(p.files || []); } else { setFileManagerError(p.error || 'Unbekannter Fehler'); } setFileManagerLoading(false); } // Datei-Manager: Datei wurde geloescht (vom Diagnostic oder dieser App) if (message.type === ('file_deleted' as any)) { const p: any = message.payload || {}; if (p.path) { setFileManagerFiles(prev => prev.filter(f => f.path !== p.path)); setFileManagerSelected(prev => { if (!prev.has(p.path)) return prev; const next = new Set(prev); next.delete(p.path); return next; }); } } // ARIA bittet um GPS-Tracking An/Aus (Tool request_location_tracking) if (message.type === ('location_tracking' as any)) { const p: any = message.payload || {}; const on = !!p.on; const reason = (p.reason as string) || 'ARIA'; if (on) { gpsTrackingService.start(reason).catch(() => {}); } else { gpsTrackingService.stop(reason); } } // Datei-Manager: ZIP-Response (Multi-Download) if (message.type === ('file_zip_response' as any)) { const p: any = message.payload || {}; if (p.requestId && p.requestId !== fileZipPending.current) return; // veraltet fileZipPending.current = null; setFileZipBusy(false); if (!p.ok || !p.data) { ToastAndroid.show('ZIP fehlgeschlagen: ' + (p.error || 'unbekannt'), ToastAndroid.LONG); return; } // base64 → in Downloads-Ordner schreiben (async () => { try { const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const dir = RNFS.DownloadDirectoryPath; const filePath = `${dir}/aria-files-${ts}.zip`; await RNFS.writeFile(filePath, p.data, 'base64'); ToastAndroid.show(`ZIP gespeichert: ${filePath} (${Math.round((p.size||0)/1024)} KB)`, ToastAndroid.LONG); } catch (e: any) { ToastAndroid.show('ZIP speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG); } })(); } // Datei-Manager: Einzel-Datei-Download. ChatScreen subscribet auch auf // file_response — der versucht aber nur Chat-Bubble-Attachments zu // patchen und macht nix wenn die requestId nicht zu einer Nachricht // passt. Hier behandeln wir die Manager-initiierten Downloads // (requestId-Praefix 'single-' aus bulkDownload). Schreibt nach // ~/Download/ wie der ZIP-Pfad. if (message.type === ('file_response' as any)) { const p: any = message.payload || {}; const reqId = (p.requestId as string) || ''; const isDownload = reqId.startsWith('single-'); const isOpen = reqId.startsWith('open-'); if (!isDownload && !isOpen) return; // andere Caller (ChatScreen etc.) if (p.error) { ToastAndroid.show((isOpen ? 'Öffnen' : 'Download') + ' fehlgeschlagen: ' + p.error, ToastAndroid.LONG); return; } const b64 = (p.base64 as string) || ''; if (!b64) return; const fileName = (p.name as string) || (p.serverPath as string || '').split('/').pop() || 'aria-download'; (async () => { try { if (isOpen) { // Open-Pfad: nach Caches schreiben + per FileOpener mit System- // Viewer oeffnen. Caches damit der Speicher kein Dauer-Muell wird. const dir = RNFS.CachesDirectoryPath; const target = `${dir}/${fileName}`; await RNFS.writeFile(target, b64, 'base64'); const mime = (p.mimeType as string) || guessMimeFromName(fileName); if (FileOpener?.open) { try { await FileOpener.open(target, mime); } catch (e: any) { ToastAndroid.show('Öffnen fehlgeschlagen: ' + (e?.message || e), ToastAndroid.LONG); } } else { ToastAndroid.show('FileOpener-Modul nicht verfügbar — APK neu bauen', ToastAndroid.LONG); } return; } // Download-Pfad: nach Downloads-Ordner schreiben, mit Suffix bei // Namens-Konflikt damit nichts ueberschrieben wird. const dir = RNFS.DownloadDirectoryPath; const filePath = `${dir}/${fileName}`; let target = filePath; let i = 1; while (await RNFS.exists(target)) { const dot = fileName.lastIndexOf('.'); const base = dot > 0 ? fileName.slice(0, dot) : fileName; const ext = dot > 0 ? fileName.slice(dot) : ''; target = `${dir}/${base} (${i})${ext}`; i++; } await RNFS.writeFile(target, b64, 'base64'); const sizeKb = Math.round(((b64.length * 0.75)) / 1024); ToastAndroid.show(`Gespeichert: ${target.split('/').pop()} (${sizeKb} KB)`, ToastAndroid.LONG); } catch (e: any) { ToastAndroid.show('Speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG); } })(); } // Datei-Manager: Versions-Liste einer Datei if (message.type === ('file_version_list_response' as any)) { const p: any = message.payload || {}; setVersionsLoading(false); if (!p.ok) { setVersionsError(p.error || 'Unbekannter Fehler'); setVersionsList([]); } else { setVersionsError(''); setVersionsList(p.versions || []); } } // Datei-Manager: Versions-Inhalt (Download einer alten Version) if (message.type === ('file_version_download_response' as any)) { const p: any = message.payload || {}; if (p.requestId && p.requestId !== versionDlPending.current) return; versionDlPending.current = null; if (!p.ok) { ToastAndroid.show('Download fehlgeschlagen: ' + (p.error || 'unbekannt'), ToastAndroid.LONG); return; } // base64 → Downloads-Ordner. Hash als Suffix damit Original nicht // ueberschrieben wird wenn beide Versionen nebeneinander vorliegen // sollen. (async () => { try { const baseName = (p.name as string) || 'aria-version'; const shortHash = (p.hash as string || '').slice(0, 7); const dot = baseName.lastIndexOf('.'); const stem = dot > 0 ? baseName.slice(0, dot) : baseName; const ext = dot > 0 ? baseName.slice(dot) : ''; const dir = RNFS.DownloadDirectoryPath; let target = `${dir}/${stem}@${shortHash}${ext}`; let i = 1; while (await RNFS.exists(target)) { target = `${dir}/${stem}@${shortHash}_${i}${ext}`; i++; } await RNFS.writeFile(target, p.base64, 'base64'); const sizeKb = Math.round(((p.base64.length * 0.75)) / 1024); ToastAndroid.show(`Gespeichert: ${target.split('/').pop()} (${sizeKb} KB)`, ToastAndroid.LONG); } catch (e: any) { ToastAndroid.show('Speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG); } })(); } // Datei-Manager: Restore-Bestaetigung if (message.type === ('file_version_restore_response' as any)) { const p: any = message.payload || {}; if (!p.ok) { ToastAndroid.show('Restore fehlgeschlagen: ' + (p.error || 'unbekannt'), ToastAndroid.LONG); return; } ToastAndroid.show(`Version ${(p.hash || '').slice(0,7)} ist jetzt aktiv`, ToastAndroid.SHORT); // Versions-Liste neu laden damit der neue restore-Commit auftaucht if (versionsOpen) { setVersionsLoading(true); rvs.send('file_version_list_request' as any, { path: versionsOpen.path }); } // File-Liste auch refreshen (mtime hat sich geaendert) if (fileManagerOpen) { setFileManagerLoading(true); rvs.send('file_list_request' as any, {}); } } // 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(); localLogSub.remove(); }; }, []); // Datei-Manager: Auto-Reload bei RVS-Reconnect — sonst zeigt das offene // Modal den Fehler "Connection refused" ewig an, obwohl die Verbindung // schon wieder da ist. Triggered nur wenn das Modal gerade offen ist. useEffect(() => { const unsub = rvs.onStateChange((state) => { if (state === 'connected' && fileManagerOpen) { setFileManagerError(''); setFileManagerLoading(true); rvs.send('file_list_request' as any, {}); } }); return () => unsub(); }, [fileManagerOpen]); // Beim Oeffnen des Datei-Managers: Projekt-Liste laden fuer den Filter. useEffect(() => { if (!fileManagerOpen) return; brainApi.listProjects(true) .then(list => setFileFilterProjects(list.map(p => ({ id: p.id, name: p.name })))) .catch(() => {}); // Default-Filter: fokussiertes Projekt aus AsyncStorage (falls Stefan // grade in einem drin ist), sonst "alle". Multi-Threading: Focus ist // App-lokal, kein Brain-Query mehr. AsyncStorage.getItem('aria_focused_project_id') .then(pid => { if (pid) setFileFilterProjectId(pid); }) .catch(() => {}); }, [fileManagerOpen]); // --- 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(async (value: boolean) => { if (value && Platform.OS === 'android') { try { const granted = await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION, { title: 'ARIA — Standort an Anfragen anhaengen', message: 'Damit ARIA bei Anfragen wie "Wo ist der naechste...?" den ' + 'Standort kennt, darf die App den ungefaehren Standort lesen. ' + 'Wird nur bei jeder Anfrage einmal abgerufen, nicht im Hintergrund.', buttonPositive: 'Erlauben', buttonNegative: 'Abbrechen', }, ); if (granted !== PermissionsAndroid.RESULTS.GRANTED) { ToastAndroid.show('Standort-Berechtigung abgelehnt', ToastAndroid.SHORT); return; } } catch (err) { console.warn('[Settings] GPS-Permission Request gescheitert:', err); return; } } setGpsEnabled(value); AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {}); }, []); // --- Hintergrund-Modus Toggle --- const handleBackgroundModeToggle = useCallback(async (value: boolean) => { setBackgroundMode(value); AsyncStorage.setItem('aria_background_mode', String(value)).catch(() => {}); try { if (value) { // Permission fuer Notification (Android 13+) — sonst sieht der User // den Hintergrund-Modus nicht und wundert sich if (Platform.OS === 'android' && Platform.Version >= 33) { await PermissionsAndroid.request( 'android.permission.POST_NOTIFICATIONS' as any, { title: 'Hintergrund-Modus', message: 'ARIA zeigt eine Notification damit die App im Hintergrund laufen darf.', buttonPositive: 'Erlauben', buttonNegative: 'Spaeter', }, ); } await acquireBackgroundAudio('background'); ToastAndroid.show('Hintergrund-Modus aktiv', ToastAndroid.SHORT); } else { await releaseBackgroundAudio('background'); ToastAndroid.show('Hintergrund-Modus aus', ToastAndroid.SHORT); } } catch (err: any) { console.warn('[Settings] Background-Toggle gescheitert:', err?.message || err); } }, []); // --- System-Hints Toggle --- const handleShowSystemHintsToggle = useCallback((value: boolean) => { setShowSystemHints(value); AsyncStorage.setItem('aria_show_hints', String(value)).catch(() => {}); }, []); const handleShowSourceToggle = useCallback((value: boolean) => { setShowSource(value); AsyncStorage.setItem('aria_show_source', 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)} /> {/* Datei-Manager Modal */} setFileManagerOpen(false)} > setFileManagerOpen(false)} style={{padding:8}}> ‹ Dateien { setFileManagerError(''); setFileManagerLoading(true); rvs.send('file_list_request' as any, {}); }} style={{padding:8}} > 🔄 {(['all','aria','user'] as const).map(f => ( setFileManagerFilter(f)} style={{ paddingVertical:6, paddingHorizontal:12, borderRadius:14, backgroundColor: fileManagerFilter === f ? '#0096FF' : '#1E1E2E', }} > {f === 'all' ? 'Alle' : f === 'aria' ? 'Von ARIA' : 'Von dir'} ))} {/* Projekt-Filter: scrollbare Pill-Reihe. „Alle Projekte" + „Hauptchat" + ein Pill pro Projekt. Default = aktives Projekt (siehe useEffect oben). */} {[ { id: '__all__', name: '📁 Alle Projekte' }, { id: '', name: '💬 Hauptchat' }, ...fileFilterProjects, ].map(p => ( setFileFilterProjectId(p.id)} style={{ paddingVertical:6, paddingHorizontal:12, borderRadius:14, backgroundColor: fileFilterProjectId === p.id ? '#34C759' : '#1E1E2E', }} > {p.name} ))} {fileManagerLoading ? ( Lade... ) : fileManagerError ? ( {fileManagerError} ) : (() => { // Visible files (Filter+Suche) let files = fileManagerFiles; if (fileManagerFilter === 'aria') files = files.filter(f => f.fromAria); else if (fileManagerFilter === 'user') files = files.filter(f => !f.fromAria); // Projekt-Filter: '__all__' = alles, '' = Hauptchat (kein project_id), // sonst exakte project_id-Match. if (fileFilterProjectId !== '__all__') { files = files.filter(f => (f.projectId || '') === fileFilterProjectId); } if (fileManagerSearch) { const q = fileManagerSearch.toLowerCase(); files = files.filter(f => f.name.toLowerCase().includes(q)); } const visiblePaths = files.map(f => f.path); const selectedHere = visiblePaths.filter(p => fileManagerSelected.has(p)); const allSelected = visiblePaths.length > 0 && selectedHere.length === visiblePaths.length; const fmtSize = (b: number) => b < 1024 ? `${b} B` : b < 1024*1024 ? `${(b/1024).toFixed(1)} KB` : `${(b/1024/1024).toFixed(1)} MB`; const toggleSelectAll = () => { setFileManagerSelected(prev => { const next = new Set(prev); if (allSelected) visiblePaths.forEach(p => next.delete(p)); else visiblePaths.forEach(p => next.add(p)); return next; }); }; const toggleOne = (p: string) => { setFileManagerSelected(prev => { const next = new Set(prev); if (next.has(p)) next.delete(p); else next.add(p); return next; }); }; const bulkDelete = () => { const paths = [...fileManagerSelected]; if (!paths.length) return; Alert.alert( `${paths.length} Dateien löschen?`, 'In allen Chat-Bubbles werden sie als gelöscht markiert.', [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Löschen', style: 'destructive', onPress: () => { rvs.send('file_delete_batch_request' as any, { paths, requestId: 'batch-' + Date.now() }); setFileManagerSelected(new Set()); ToastAndroid.show(`${paths.length} Lösch-Befehle gesendet…`, ToastAndroid.SHORT); }}, ], ); }; const bulkDownload = () => { const paths = [...fileManagerSelected]; if (!paths.length) return; // 1 Datei: einfach via file_request (existing pattern). ZIP nur bei 2+. if (paths.length === 1) { rvs.send('file_request' as any, { serverPath: paths[0], requestId: 'single-' + Date.now() }); ToastAndroid.show('Datei wird heruntergeladen…', ToastAndroid.SHORT); return; } const reqId = 'zip-' + Date.now(); fileZipPending.current = reqId; setFileZipBusy(true); rvs.send('file_zip_request' as any, { paths, requestId: reqId }); ToastAndroid.show(`ZIP wird erstellt (${paths.length} Dateien)…`, ToastAndroid.LONG); }; return ( <> {/* Bulk-Bar */} {allSelected && ✓} Alle markieren {fileManagerSelected.size > 0 && ( <> · {fileManagerSelected.size} ausgewählt {fileZipBusy ? '⏳ ZIP…' : (fileManagerSelected.size > 1 ? '⬇ ZIP' : '⬇ Download')} 🗑 Löschen )} {!files.length ? ( Keine Dateien ) : files.map(f => { const selected = fileManagerSelected.has(f.path); return ( toggleOne(f.path)} activeOpacity={0.7} style={{ backgroundColor: selected ? '#1E2C44' : '#0D0D1A', padding:12, borderRadius:8, marginBottom:8, flexDirection:'row', alignItems:'center', gap:8, borderWidth: selected ? 1 : 0, borderColor:'#0096FF', }} > {selected && ✓} {f.fromAria ? 'ARIA' : 'USER'} {f.name} {fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')} { rvs.send('file_request' as any, { serverPath: f.path, requestId: 'open-' + Date.now(), }); ToastAndroid.show('Öffne ' + f.name + '…', ToastAndroid.SHORT); }} style={{padding:8}} > 👁 { rvs.send('file_request' as any, { serverPath: f.path, requestId: 'single-' + Date.now(), }); ToastAndroid.show('Download läuft…', ToastAndroid.SHORT); }} style={{padding:8}} > ⬇ { // path-relativ-zu-uploads = nur der Dateiname, // weil der File-Manager-Bereich flach ist setVersionsOpen({name: f.name, path: f.name}); setVersionsList([]); setVersionsError(''); setVersionsLoading(true); rvs.send('file_version_list_request' as any, { path: f.name }); }} style={{padding:8}} > 🕒 { Alert.alert( 'Datei löschen?', `"${f.name}"\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`, [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Löschen', style: 'destructive', onPress: () => { rvs.send('file_delete_request' as any, { path: f.path }); ToastAndroid.show('Lösch-Befehl gesendet…', ToastAndroid.SHORT); }}, ], ); }} style={{padding:8}} > 🗑 ); })} ); })()} {/* Versions-Modal — Historie pro Datei (auto-commit-git im diagnostic) */} setVersionsOpen(null)} > setVersionsOpen(null)} > {}} style={{backgroundColor:'#0D0D1A', borderWidth:1, borderColor:'#1E1E2E', borderRadius:8, width:'90%', maxHeight:'80%'}} > Versionen — {versionsOpen?.name || ''} setVersionsOpen(null)} style={{padding:6}}> ✕ {versionsLoading && ( Lade... )} {!!versionsError && ( {versionsError} )} {!versionsLoading && !versionsError && versionsList.length === 0 && ( Noch keine Versions-Historie (Datei kommt erst nach dem nächsten Auto-Commit in den Index). )} {versionsList.map(v => ( {v.isCurrent && ( AKTIV )} {v.hash.slice(0,7)} {v.subject || ''} {new Date(v.ts).toLocaleString('de-DE')} { if (!versionsOpen) return; const reqId = 'verdl_' + Date.now() + '_' + Math.floor(Math.random()*100000); versionDlPending.current = reqId; rvs.send('file_version_download_request' as any, { path: versionsOpen.path, hash: v.hash, requestId: reqId, }); ToastAndroid.show('Download läuft…', ToastAndroid.SHORT); }} style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#0096FF22'}} > ⬇ {!v.isCurrent && ( { if (!versionsOpen) return; Alert.alert( 'Version aktiv setzen?', `Hash ${v.hash.slice(0,7)} wird als neue aktive Version gespeichert.\n\nDie aktuelle Version bleibt in der Historie und kann später ebenfalls wiederhergestellt werden.`, [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Restore', onPress: () => { rvs.send('file_version_restore_request' as any, { path: versionsOpen.path, hash: v.hash, }); ToastAndroid.show('Restore läuft…', ToastAndroid.SHORT); }}, ], ); }} style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#0096FF'}} > ⟲ )} ))} {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 Abgelaufenes Zertifikat akzeptieren Notlösung, wenn das Zertifikat des Servers abgelaufen ist. Die Verbindung bleibt verschlüsselt, und es wird weiter geprüft, dass es wirklich das Zertifikat dieses Servers ist — nur das Ablaufdatum wird ignoriert. Wieder ausschalten, sobald das Zertifikat erneuert ist. {/* === 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. {/* GPS-Tracking (kontinuierlich) — fuer near()-Watcher */} GPS-Tracking (kontinuierlich) Sendet alle ~15s deine Position an ARIA (wenn du dich {'>'}30m bewegt hast). Nur noetig fuer GPS-basierte Trigger wie Blitzer-Warner (near()-Conditions). ARIA kann das auch selbst an-/abschalten wenn sie einen GPS-Watcher anlegt. Akku-Verbrauch erhoeht — bei langer Fahrt einplanen. { if (v) gpsTrackingService.start('manuell').catch(() => {}); else gpsTrackingService.stop('manuell'); }} trackColor={{ false: '#2A2A3E', true: '#FF9500' }} thumbColor={gpsTracking ? '#FFFFFF' : '#666680'} /> {/* Background-GPS opt-in — Default AUS. Braucht ACCESS_BACKGROUND_LOCATION (User muss in Android-Settings 'Immer erlauben' aktivieren). */} GPS auch im Hintergrund Damit ARIA auch unterwegs deine aktuelle Position kennt wenn die App im Hintergrund ist (Auto, Handy-Tasche). Standard: aus. {'\n\n'} Android verlangt fuer Background-GPS, dass du in den System-Einstellungen unter Standort "Immer erlauben" auswaehlst. Beim Aktivieren wird Android-Settings geoeffnet falls noetig. {'\n\n'} Akku-Verbrauch: ~3-5% mehr pro Tag durch dauerhaftes Polling. { if (v) { const ok = await ensureBackgroundLocationPermission(); if (!ok) { // User muss in Android-Settings auf "Immer erlauben" — Toggle // bleibt aus bis er zurueckkommt und nochmal tippt. return; } await setBackgroundGpsEnabled(true); setBgGpsEnabled(true); // Wenn Tracking bereits laeuft: neu starten damit der // Foreground-Service jetzt mit location-Slot kommt if (gpsTrackingService.isActive()) { gpsTrackingService.stop('bg-toggle'); gpsTrackingService.start('bg-aktiviert').catch(() => {}); } ToastAndroid.show('Background-GPS aktiviert', ToastAndroid.SHORT); } else { await setBackgroundGpsEnabled(false); setBgGpsEnabled(false); ToastAndroid.show('Background-GPS aus — nur noch Foreground', ToastAndroid.SHORT); } }} trackColor={{ false: '#2A2A3E', true: '#FF3B30' }} thumbColor={bgGpsEnabled ? '#FFFFFF' : '#666680'} /> {/* === Bubble-Anzeige === */} Chat-Bubbles System-Hints in Bubbles anzeigen Wenn aktiviert: GPS-Position, Barge-In-Hinweise und andere System-Praefixe in eckigen Klammern bleiben in der User-Bubble sichtbar (Debug). Standardmaessig versteckt — Brain bekommt sie trotzdem, sie sind nur fuer dich nicht relevant. Antwort-Quelle anzeigen Kleiner Badge an ARIAs Bubbles: ob die Antwort vom schnellen lokalen Modell, von Claude oder per Direkt-Befehl kam. Nur fuer dich interessant (Technik) — standardmaessig aus. {/* === Hintergrund-Modus === */} Hintergrund-Modus App im Hintergrund weiterlaufen Haelt die Verbindung zu ARIA auch dann offen wenn die App minimiert ist. Sonst pausiert Android nach ~30s die JS-Engine und Timer-/Watcher- Trigger kommen nicht durch. Notification "ARIA aktiv" bleibt sichtbar waehrend der Modus laeuft (das ist Android-Vorschrift fuer Foreground- Services). Akku-Mehrverbrauch minimal solange ARIA nichts tut. {'\n\n'} Wenn nach Akku-Optimierung Trigger trotzdem nicht durchkommen: Android-Einstellungen → Apps → ARIA Cockpit → Akku → "Uneingeschraenkt" setzen. )} {/* === Spracheingabe (geraetelokal) === */} {currentSection === 'voice_input' && (<> Spracheingabe Barge-in (unterbrechen) AUS (empfohlen): ARIA spricht ihre Antwort ZU ENDE, dann geht das Mikro auf — sauber, kein Selbst-Echo, du hoerst sie ganz. AN: du kannst sie waehrend des Sprechens per Wake-Wort unterbrechen. { setBargeIn(v); saveBargeInEnabled(v).catch(() => {}); }} trackColor={{ false: '#2A2A3E', true: '#0096FF' }} thumbColor={bargeIn ? '#FFFFFF' : '#666680'} /> Stille-Toleranz Wie lange du eine Sprechpause machen darfst, bevor die Aufnahme automatisch beendet und gesendet wird. Hoeher = mehr Zeit zum Nachdenken (z.B. im Auto); niedriger = schnelleres Senden. Default: {(STT_ENDPOINT_DEFAULT_MS / 1000).toFixed(1)}s. { const next = Math.max(STT_ENDPOINT_MIN_MS / 1000, Math.round((sttEndpointSec - 0.5) * 10) / 10); setSttEndpointSec(next); AsyncStorage.setItem(STT_ENDPOINT_STORAGE_KEY, String(Math.round(next * 1000))); }} disabled={sttEndpointSec <= STT_ENDPOINT_MIN_MS / 1000} > −0.5 {sttEndpointSec.toFixed(1)} s { const next = Math.min(STT_ENDPOINT_MAX_MS / 1000, Math.round((sttEndpointSec + 0.5) * 10) / 10); setSttEndpointSec(next); AsyncStorage.setItem(STT_ENDPOINT_STORAGE_KEY, String(Math.round(next * 1000))); }} disabled={sttEndpointSec >= STT_ENDPOINT_MAX_MS / 1000} > +0.5 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 {/* "Stille-Pegel (dB)"-Regler entfernt: der aktive Streaming-STT nutzt einen adaptiven Rausch-Boden (automatisch), ein manueller dB-Wert war wirkungslos. Rauschen-als-Wort verhindert das STT-Modell selbst (no_speech_prob-Filter), nicht die dB-Schwelle. */} setShowVadInfo(false)} > Stille-Pegel (dB) Lautstaerken werden in Dezibel (dB) gemessen — negative Werte, je hoeher (naeher an 0), desto lauter.{'\n\n'} Standard: automatisch. Die App misst die ersten 500ms Hintergrundpegel und setzt die Stille-Schwelle auf Baseline + 6 dB. Funktioniert in den meisten Umgebungen.{'\n\n'} Manuell: Pegel unter dem eingestellten Wert gilt als "Stille" → Aufnahme stoppt.{'\n\n'} Faustregel:{'\n'} • −45 dB sehr empfindlich (stoppt schnell, auch bei Atmen){'\n'} • −38 dB ausgewogen (typische Bueroumgebung){'\n'} • −25 dB unempfindlich (laute Umgebung, nur klare Sprache zaehlt){'\n\n'} Niedrigere Zahl (z.B. −50) = sensibler.{'\n'} Hoehere Zahl (z.B. −20) = robuster gegen Hintergrundlaerm, braucht aber lautere Sprache. setShowVadInfo(false)} > OK )} {/* === 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]} ))} Empfindlichkeit Wie leicht das Wake-Word anspringt. Hoeher = strenger = weniger Fehlauslösung (z.B. durch Musik/Radio, die das Mikro mithoert — der Echo-Canceler kann nur ARIAs eigene Stimme rausrechnen, nicht Spotify), aber du musst evtl. deutlicher sprechen. Default: {WAKE_THRESHOLD_DEFAULT.toFixed(2)}. Wird beim „Speichern + Aktivieren" uebernommen. { const next = Math.max(WAKE_THRESHOLD_MIN, Math.round((wakeThreshold - 0.05) * 100) / 100); setWakeThreshold(next); saveWakeThreshold(next); }} disabled={wakeThreshold <= WAKE_THRESHOLD_MIN} > −0.05 {wakeThreshold.toFixed(2)} { const next = Math.min(WAKE_THRESHOLD_MAX, Math.round((wakeThreshold + 0.05) * 100) / 100); setWakeThreshold(next); saveWakeThreshold(next); }} disabled={wakeThreshold >= WAKE_THRESHOLD_MAX} > +0.05 { 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'} /> Auch bei gesperrtem Bildschirm zuhören AUS (empfohlen): das Wake-Wort greift nur, wenn die App offen ist — im Hintergrund sind die meisten „Trigger" Fehlalarme (TV, Husten). AN: ARIA hört auch bei gesperrtem Bildschirm / im Hintergrund auf „{KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS] || wakeKeyword}" — mehr Fehlauslöser möglich. { setBgWake(val); saveBgWakeEnabled(val).catch(() => {}); wakeWordService.setBgWakeEnabled(val); }} trackColor={{ false: '#2A2A3E', true: '#0096FF' }} thumbColor={bgWake ? '#FFFFFF' : '#666680'} /> Wake-Wort per Voxtral bestätigen Gegen Musik-Fehltrigger: nach „{KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS] || wakeKeyword}" prüft Voxtral kurz nach, ob's wirklich das Wake-Wort war (oder nur Musik/TV) — erst dann Gong + Mikro. Kostet ~0,5–1 s Extra vor dem Gong. Empfohlen zusammen mit Hintergrund-Zuhören. { setWakeConfirm(val); saveWakeConfirmEnabled(val).catch(() => {}); wakeWordService.setWakeConfirmEnabled(val); }} trackColor={{ false: '#2A2A3E', true: '#0096FF' }} thumbColor={wakeConfirm ? '#FFFFFF' : '#666680'} /> Weiterreden nach der Antwort Nach einer gesprochenen ARIA-Antwort geht das Mikro auf — du kannst ohne Wake-Word weiterreden. Fängst du nicht innerhalb der „Stille-Toleranz" (Sektion Spracheingabe) an, geht's zurück aufs Wake-Word. Reine Steuerbefehle beenden sofort. Ein separates Zeitfenster gibt es nicht mehr — es zählt überall derselbe Stille-Wert. )} {/* === Voice-ID Enrollment (Sprecher-Erkennung) === */} {currentSection === 'voice_id' && (<> Stimme einrichten )} {/* === 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 )} {/* === Update-Cache === */} Update-Cache Heruntergeladene APK-Dateien fuer App-Updates. Werden automatisch beim App-Start und vor jedem neuen Download geloescht — der Button ist fuer den Notfall (z.B. wenn ein Download haengen geblieben ist). {apkCacheInfo === null ? '...' : apkCacheInfo.count === 0 ? 'leer' : `${apkCacheInfo.count} APK${apkCacheInfo.count === 1 ? '' : 's'} · ${apkCacheInfo.totalMB.toFixed(1)}MB`} { const res = await updateService.cleanupOldApks(); ToastAndroid.show( res.removed === 0 ? 'Update-Cache war schon leer' : `${res.removed} APK${res.removed === 1 ? '' : 's'} geloescht (${res.freedMB.toFixed(1)}MB frei)`, ToastAndroid.SHORT, ); const info = await updateService.getApkCacheSize(); setApkCacheInfo(info); }} > Update-Cache leeren {/* === TTS-Cache === */} TTS-Cache Gespeicherte Sprachausgaben (WAV pro Antwort) — werden fuer den Play-Button und Auto-Resume nach Anrufen genutzt. Loeschen unterbricht keine laufende Wiedergabe, alte Antworten lassen sich danach nur nicht mehr abspielen. {ttsCacheInfo === null ? '...' : ttsCacheInfo.count === 0 ? 'leer' : `${ttsCacheInfo.count} WAV${ttsCacheInfo.count === 1 ? '' : 's'} · ${ttsCacheInfo.totalMB.toFixed(1)}MB`} { const res = await audioService.clearTtsCache(); ToastAndroid.show( res.removed === 0 ? 'TTS-Cache war schon leer' : `${res.removed} WAV${res.removed === 1 ? '' : 's'} geloescht (${res.freedMB.toFixed(1)}MB frei)`, ToastAndroid.SHORT, ); const info = await audioService.getTtsCacheSize(); setTtsCacheInfo(info); }} > TTS-Cache leeren {/* === Reparatur === */} Reparatur Container gezielt neu starten — wenn die Voice-Bridge, das Gehirn oder die Vector-DB haengt. Restart dauert wenige Sekunden, laufende Anfragen gehen verloren. {[ { name: 'aria-bridge', label: '🚨 aria-bridge neu (Voice + RVS)' }, { name: 'aria-brain', label: '🚨 aria-brain neu (Agent + Memory)' }, { name: 'aria-qdrant', label: '🚨 aria-qdrant neu (Vector-DB)' }, ].map(c => ( { Alert.alert( `${c.name} neu starten?`, 'Restart in wenigen Sekunden. Laufende Anfragen gehen verloren.', [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Neu starten', style: 'destructive', onPress: () => { rvs.send('container_restart' as any, { name: c.name }); ToastAndroid.show(`${c.name} wird neu gestartet…`, ToastAndroid.LONG); }}, ], ); }} > {c.label} ))} )} {/* === Datei-Manager === */} {currentSection === 'files' && (<> Dateien Alle Dateien aus /shared/uploads/ — was ARIA generiert hat und was du hochgeladen hast. Beim Löschen wird die Bubble in App + Diagnostic als gelöscht markiert. { setFileManagerError(''); setFileManagerLoading(true); setFileManagerOpen(true); rvs.send('file_list_request' as any, {}); }} > {'📂 Datei-Manager öffnen'} )} {/* === Projekte === */} {currentSection === 'projects' && (<> Projekte Thread-Bündel im Hauptchat. Tap auf ein Projekt → aktivieren, alle weiteren Nachrichten gehen dort rein. Long-Press → bearbeiten. „+ Neu" oder zu ARIA: „lass uns ein Projekt anlegen". )} {/* === Gedaechtnis === */} {currentSection === 'memory' && (<> Gedächtnis Alle Memory-Einträge aus ARIAs Vector-DB. Tippen zum Bearbeiten — mit Anhängen, pinned-Status, Tags. Neue Einträge anlegen via "+ Neu". )} {/* === Trigger === */} {currentSection === 'triggers' && (<> Trigger Timer (einmalige Erinnerung) + Watcher (recurring mit Condition, z.B. GPS-near). Toggle aktiv/inaktiv, Tap zum Bearbeiten, "+ Neu" zum Anlegen. )} {/* === Skills === */} {currentSection === 'skills' && (<> Skills Wiederverwendbare Python-Skills die ARIA selbst gebaut hat oder die Du importiert hast. Toggle aktiv/inaktiv, Tap fuer Details + Run + Logs. Code-Aenderungen macht ARIA via ihr skill_update Brain-Tool — hier nur Manifest-Felder + Run + Cleanup. )} {/* === OAuth-Apps === */} {currentSection === 'oauth' && (<> OAuth-Apps Verbinde ARIA mit externen Services (Spotify, Dropbox, Discord, ...). Trag client_id + client_secret aus dem Developer-Dashboard des Anbieters ein, dann "Autorisieren ↗" tippen. Custom-Services kannst Du via "+ Custom" anlegen — ARIA kann das auch selbst per Chat ("verbinde mich mit X"). )} {/* === Logs === */} {currentSection === 'protocol' && (<> Protokoll {/* Verbose-Logging-Toggle */} Verbose Logging { setVerboseLogging(v); setVerboseLoggingState(v); }} trackColor={{ false: '#3A3A52', true: '#0096FF' }} thumbColor={verboseLogging ? '#FFFFFF' : '#666680'} /> Wenn aus: console.log wird global stummgeschaltet (Speicher schonen). Warnungen und Fehler bleiben immer aktiv. Bei Bedarf einschalten zum Debuggen via adb logcat. {/* Debug-Logs an Bridge: scharf nur wenn aktiv gebraucht */} Debug-Logs an Bridge { setDebugLogsToBridge(v); setDebugLogsToBridgeState(v); }} trackColor={{ false: '#3A3A52', true: '#FF9500' }} thumbColor={debugLogsToBridge ? '#FFFFFF' : '#666680'} /> Schickt detaillierte Diagnose-Logs (Wake-Word-Pipeline, Audio-Focus, Background-Service) per RVS an die Bridge — abrufbar via `curl /api/app-log?lines=N` ohne ADB. Default AUS damit kein unnoetiger Traffic + Disk-Schreiben. Crash-Reports (Errors) gehen IMMER, dieser Toggle betrifft nur Info-Logs. {/* 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} ARIA {'\u2014'} Autonomous Reasoning & Intelligence Assistant.{'\n'} Stefans Kommandozentrale.{'\n'} Gebaut mit React Native + TypeScript. { const updateService = require('../services/updater').default; updateService.checkForUpdateManual(); }} > 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', }, infoBtn: { width: 22, height: 22, borderRadius: 11, borderWidth: 1.5, borderColor: '#0096FF', alignItems: 'center', justifyContent: 'center', }, infoBtnText: { color: '#0096FF', fontSize: 13, fontWeight: '700', fontStyle: 'italic', lineHeight: 16, }, modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', alignItems: 'center', padding: 20, }, modalCard: { backgroundColor: '#1E1E2E', borderRadius: 14, padding: 20, maxWidth: 460, width: '100%', }, modalTitle: { color: '#FFFFFF', fontSize: 18, fontWeight: '700', marginBottom: 12, }, modalText: { color: '#E0E0F0', fontSize: 14, lineHeight: 20, }, 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;