1453 lines
45 KiB
TypeScript
1453 lines
45 KiB
TypeScript
/**
|
||
* 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,
|
||
} from '../services/audio';
|
||
import wakeWordService, {
|
||
BUILTIN_KEYWORDS,
|
||
DEFAULT_KEYWORD,
|
||
WAKE_ACCESS_KEY_STORAGE,
|
||
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';
|
||
|
||
// Container-Farben fuer Live-Logs
|
||
const SOURCE_COLORS: Record<string, string> = {
|
||
'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<ConnectionState>('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<LogTab>('live');
|
||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||
const [events, setEvents] = useState<EventEntry[]>([]);
|
||
const [connLog, setConnLog] = useState<ConnectionLogEntry[]>(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<number>(TTS_PREROLL_DEFAULT_SEC);
|
||
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
|
||
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
|
||
const [wakeAccessKey, setWakeAccessKey] = useState<string>('');
|
||
const [wakeAccessKeyVisible, setWakeAccessKeyVisible] = useState(false);
|
||
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
|
||
const [wakeStatus, setWakeStatus] = useState<string>('');
|
||
const [editingPath, setEditingPath] = useState(false);
|
||
const [xttsVoice, setXttsVoice] = useState('');
|
||
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
||
const [availableVoices, setAvailableVoices] = useState<Array<{name: string, size: number}>>([]);
|
||
const [voiceCloneVisible, setVoiceCloneVisible] = useState(false);
|
||
const [tempPath, setTempPath] = useState('');
|
||
|
||
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(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(WAKE_ACCESS_KEY_STORAGE).then(saved => {
|
||
if (saved) setWakeAccessKey(saved);
|
||
});
|
||
AsyncStorage.getItem(WAKE_KEYWORD_STORAGE).then(saved => {
|
||
if (saved) setWakeKeyword(saved);
|
||
});
|
||
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);
|
||
// In Produktion: Wert in AsyncStorage persistieren
|
||
}, []);
|
||
|
||
// --- 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 (
|
||
<>
|
||
<QRScanner
|
||
visible={scannerVisible}
|
||
onScan={handleQRScan}
|
||
onClose={() => setScannerVisible(false)}
|
||
/>
|
||
<VoiceCloneModal
|
||
visible={voiceCloneVisible}
|
||
onClose={() => setVoiceCloneVisible(false)}
|
||
/>
|
||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||
|
||
{/* === Verbindung === */}
|
||
<Text style={styles.sectionTitle}>Verbindung</Text>
|
||
<View style={styles.card}>
|
||
{/* Status-Anzeige */}
|
||
<View style={styles.statusRow}>
|
||
<View style={[styles.statusDot, { backgroundColor: connectionDotColor }]} />
|
||
<Text style={styles.statusLabel}>{connectionLabel}</Text>
|
||
{connectionState === 'connected' && (
|
||
<TouchableOpacity style={styles.disconnectButton} onPress={disconnectRVS}>
|
||
<Text style={styles.disconnectText}>Trennen</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
|
||
{/* QR-Scanner */}
|
||
<TouchableOpacity style={styles.qrButton} onPress={openQRScanner}>
|
||
<Text style={styles.qrIcon}>{'\uD83D\uDCF1'}</Text>
|
||
<Text style={styles.qrText}>QR-Code scannen (Pairing)</Text>
|
||
</TouchableOpacity>
|
||
|
||
{/* Manuelle Eingabe */}
|
||
<Text style={styles.inputLabel}>Host</Text>
|
||
<TextInput
|
||
style={styles.input}
|
||
value={manualHost}
|
||
onChangeText={setManualHost}
|
||
placeholder="z.B. aria.example.com"
|
||
placeholderTextColor="#555570"
|
||
autoCapitalize="none"
|
||
/>
|
||
|
||
<Text style={styles.inputLabel}>Port</Text>
|
||
<TextInput
|
||
style={styles.input}
|
||
value={manualPort}
|
||
onChangeText={setManualPort}
|
||
placeholder="8765"
|
||
placeholderTextColor="#555570"
|
||
keyboardType="numeric"
|
||
/>
|
||
|
||
<Text style={styles.inputLabel}>Token</Text>
|
||
<TextInput
|
||
style={styles.input}
|
||
value={manualToken}
|
||
onChangeText={setManualToken}
|
||
placeholder="Verbindungs-Token"
|
||
placeholderTextColor="#555570"
|
||
autoCapitalize="none"
|
||
secureTextEntry
|
||
/>
|
||
|
||
<TouchableOpacity style={styles.connectButton} onPress={connectManually}>
|
||
<Text style={styles.connectButtonText}>Verbinden</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* === Verbindungslog === */}
|
||
<Text style={styles.sectionTitle}>Verbindungslog</Text>
|
||
<View style={styles.card}>
|
||
<ScrollView
|
||
style={styles.connLogScroll}
|
||
nestedScrollEnabled={true}
|
||
ref={(ref) => {
|
||
// 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) => (
|
||
<View key={`cl_${idx}`} style={styles.logEntry}>
|
||
<Text style={styles.logTime}>{formatTime(entry.timestamp)}</Text>
|
||
<Text
|
||
style={[
|
||
styles.logMessage,
|
||
entry.level === 'error' && styles.logError,
|
||
entry.level === 'warn' && styles.logWarn,
|
||
]}
|
||
numberOfLines={3}
|
||
>
|
||
{entry.message}
|
||
</Text>
|
||
</View>
|
||
))
|
||
) : (
|
||
<Text style={styles.emptyLog}>Noch keine Verbindungsversuche</Text>
|
||
)}
|
||
</ScrollView>
|
||
<TouchableOpacity
|
||
style={styles.clearButton}
|
||
onPress={() => setConnLog([])}
|
||
>
|
||
<Text style={styles.clearButtonText}>Log l{'\u00F6'}schen</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* === Modus === */}
|
||
<Text style={styles.sectionTitle}>Betriebsmodus</Text>
|
||
<View style={styles.card}>
|
||
<ModeSelector currentModeId={currentMode} onModeChange={handleModeChange} />
|
||
</View>
|
||
|
||
{/* === GPS === */}
|
||
<Text style={styles.sectionTitle}>Standort</Text>
|
||
<View style={styles.card}>
|
||
<View style={styles.toggleRow}>
|
||
<View style={styles.toggleInfo}>
|
||
<Text style={styles.toggleLabel}>GPS-Position mitsenden</Text>
|
||
<Text style={styles.toggleHint}>
|
||
Standort wird automatisch an Nachrichten angehaengt
|
||
</Text>
|
||
</View>
|
||
<Switch
|
||
value={gpsEnabled}
|
||
onValueChange={handleGPSToggle}
|
||
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||
thumbColor={gpsEnabled ? '#FFFFFF' : '#666680'}
|
||
/>
|
||
</View>
|
||
</View>
|
||
|
||
{/* === Spracheingabe (geraetelokal) === */}
|
||
<Text style={styles.sectionTitle}>Spracheingabe</Text>
|
||
<View style={styles.card}>
|
||
<Text style={styles.toggleLabel}>Stille-Toleranz</Text>
|
||
<Text style={styles.toggleHint}>
|
||
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.
|
||
</Text>
|
||
<View style={styles.prerollRow}>
|
||
<TouchableOpacity
|
||
style={styles.prerollButton}
|
||
onPress={() => {
|
||
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}
|
||
>
|
||
<Text style={styles.prerollButtonText}>−0.5</Text>
|
||
</TouchableOpacity>
|
||
<Text style={styles.prerollValue}>{vadSilenceSec.toFixed(1)} s</Text>
|
||
<TouchableOpacity
|
||
style={styles.prerollButton}
|
||
onPress={() => {
|
||
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}
|
||
>
|
||
<Text style={styles.prerollButtonText}>+0.5</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Konversations-Fenster</Text>
|
||
<Text style={styles.toggleHint}>
|
||
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.
|
||
</Text>
|
||
<View style={styles.prerollRow}>
|
||
<TouchableOpacity
|
||
style={styles.prerollButton}
|
||
onPress={() => {
|
||
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}
|
||
>
|
||
<Text style={styles.prerollButtonText}>−1</Text>
|
||
</TouchableOpacity>
|
||
<Text style={styles.prerollValue}>{convWindowSec.toFixed(0)} s</Text>
|
||
<TouchableOpacity
|
||
style={styles.prerollButton}
|
||
onPress={() => {
|
||
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}
|
||
>
|
||
<Text style={styles.prerollButtonText}>+1</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
|
||
{/* === Wake-Word (geraetelokal) === */}
|
||
<Text style={styles.sectionTitle}>Wake-Word</Text>
|
||
<View style={styles.card}>
|
||
<Text style={styles.toggleHint}>
|
||
Wenn ein Picovoice-Access-Key eingetragen ist, hoert die App passiv
|
||
auf das gewaehlte Wake-Word — du kannst dich mit anderen unterhalten,
|
||
Musik laufen lassen und mit "{wakeKeyword}" eine Konversation mit
|
||
ARIA starten. Ohne Key oder bei Fehlschlag startet das Ohr direkt
|
||
eine Konversation (klassischer Modus).
|
||
</Text>
|
||
|
||
<Text style={[styles.toggleLabel, {marginTop: 16}]}>Picovoice Access Key</Text>
|
||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 6}}>
|
||
<TextInput
|
||
style={[styles.input, {flex: 1}]}
|
||
value={wakeAccessKey}
|
||
onChangeText={setWakeAccessKey}
|
||
placeholder="kostenlos auf console.picovoice.ai"
|
||
placeholderTextColor="#666680"
|
||
secureTextEntry={!wakeAccessKeyVisible}
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
/>
|
||
<TouchableOpacity
|
||
onPress={() => setWakeAccessKeyVisible(v => !v)}
|
||
style={{padding: 8}}
|
||
>
|
||
<Text style={{fontSize: 18}}>{wakeAccessKeyVisible ? '🙈' : '👁'}</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
<Text style={[styles.toggleLabel, {marginTop: 16}]}>Wake-Word</Text>
|
||
<Text style={styles.toggleHint}>
|
||
Built-In: sofort verwendbar. "ARIA" als Custom-Keyword kommt spaeter
|
||
ueber Diagnostic-Upload.
|
||
</Text>
|
||
<View style={{flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8}}>
|
||
{BUILTIN_KEYWORDS.map(kw => (
|
||
<TouchableOpacity
|
||
key={kw}
|
||
style={[
|
||
styles.keywordChip,
|
||
wakeKeyword === kw && styles.keywordChipActive,
|
||
]}
|
||
onPress={() => setWakeKeyword(kw)}
|
||
>
|
||
<Text style={[
|
||
styles.keywordChipText,
|
||
wakeKeyword === kw && styles.keywordChipTextActive,
|
||
]}>
|
||
{kw}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
|
||
<View style={{flexDirection: 'row', gap: 8, marginTop: 16, alignItems: 'center'}}>
|
||
<TouchableOpacity
|
||
style={[styles.connectButton, {flex: 1}]}
|
||
onPress={async () => {
|
||
setWakeStatus('Initialisiere...');
|
||
try {
|
||
const ok = await wakeWordService.configure(wakeAccessKey, wakeKeyword);
|
||
setWakeStatus(ok ? `✅ "${wakeKeyword}" bereit` : '❌ Fehlgeschlagen — Access Key pruefen');
|
||
} catch (err: any) {
|
||
setWakeStatus('❌ ' + String(err?.message || err).slice(0, 80));
|
||
}
|
||
setTimeout(() => setWakeStatus(''), 5000);
|
||
}}
|
||
>
|
||
<Text style={styles.connectButtonText}>Speichern + Aktivieren</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
{!!wakeStatus && (
|
||
<Text style={{marginTop: 8, fontSize: 12, color: '#8888AA'}}>{wakeStatus}</Text>
|
||
)}
|
||
</View>
|
||
|
||
{/* === Sprachausgabe (geraetelokal) === */}
|
||
<Text style={styles.sectionTitle}>Sprachausgabe</Text>
|
||
<View style={styles.card}>
|
||
<View style={styles.toggleRow}>
|
||
<View style={styles.toggleInfo}>
|
||
<Text style={styles.toggleLabel}>Sprachausgabe auf diesem Geraet</Text>
|
||
<Text style={styles.toggleHint}>
|
||
Nur lokal — andere Geraete sind unabhaengig.
|
||
Wenn aus, erscheint im Chat auch kein Mund-Button.
|
||
</Text>
|
||
</View>
|
||
<Switch
|
||
value={ttsEnabled}
|
||
onValueChange={(val) => {
|
||
setTtsEnabled(val);
|
||
AsyncStorage.setItem('aria_tts_enabled', String(val));
|
||
}}
|
||
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||
thumbColor={ttsEnabled ? '#FFFFFF' : '#666680'}
|
||
/>
|
||
</View>
|
||
|
||
{ttsEnabled && (
|
||
<View style={{marginTop: 20}}>
|
||
<Text style={styles.toggleLabel}>Puffer vor Wiedergabestart</Text>
|
||
<Text style={styles.toggleHint}>
|
||
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.
|
||
</Text>
|
||
<View style={styles.prerollRow}>
|
||
<TouchableOpacity
|
||
style={styles.prerollButton}
|
||
onPress={() => {
|
||
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}
|
||
>
|
||
<Text style={styles.prerollButtonText}>−0.5</Text>
|
||
</TouchableOpacity>
|
||
<Text style={styles.prerollValue}>{ttsPrerollSec.toFixed(1)} s</Text>
|
||
<TouchableOpacity
|
||
style={styles.prerollButton}
|
||
onPress={() => {
|
||
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}
|
||
>
|
||
<Text style={styles.prerollButtonText}>+0.5</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{ttsEnabled && (
|
||
<View style={{marginTop: 20}}>
|
||
<Text style={styles.toggleLabel}>Stimme (geraetelokal)</Text>
|
||
<Text style={styles.toggleHint}>
|
||
Eigene Wahl fuer dieses Geraet. Ohne Auswahl gilt der Diagnostic-Default.
|
||
</Text>
|
||
|
||
{/* Default-Option */}
|
||
<TouchableOpacity
|
||
style={[styles.voiceRow, xttsVoice === '' && styles.voiceRowActive]}
|
||
onPress={() => selectVoice('')}
|
||
>
|
||
<Text style={[styles.voiceRowName, xttsVoice === '' && styles.voiceRowNameActive]}>
|
||
Standard (Diagnostic-Default)
|
||
</Text>
|
||
{xttsVoice === '' && <Text style={styles.voiceRowCheck}>{'\u2713'}</Text>}
|
||
</TouchableOpacity>
|
||
|
||
{availableVoices.length === 0 ? (
|
||
<Text style={[styles.toggleHint, {marginTop: 8, textAlign: 'center'}]}>
|
||
Keine eigenen Stimmen auf dem XTTS-Server.
|
||
</Text>
|
||
) : (
|
||
availableVoices.map(v => (
|
||
<View key={v.name} style={[styles.voiceRow, xttsVoice === v.name && styles.voiceRowActive]}>
|
||
<TouchableOpacity
|
||
style={{flex: 1}}
|
||
onPress={() => selectVoice(v.name)}
|
||
>
|
||
<Text style={[styles.voiceRowName, xttsVoice === v.name && styles.voiceRowNameActive]}>
|
||
{v.name}
|
||
</Text>
|
||
<Text style={styles.voiceRowMeta}>{(v.size / 1024).toFixed(0)} KB</Text>
|
||
</TouchableOpacity>
|
||
{loadingVoice === v.name && (
|
||
<ActivityIndicator size="small" color="#0096FF" style={{marginRight: 8}} />
|
||
)}
|
||
{xttsVoice === v.name && loadingVoice !== v.name && <Text style={styles.voiceRowCheck}>{'\u2713'}</Text>}
|
||
<TouchableOpacity onPress={() => deleteVoice(v.name)} style={styles.voiceRowDelete}>
|
||
<Text style={styles.voiceRowDeleteIcon}>X</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
))
|
||
)}
|
||
|
||
<View style={{flexDirection: 'row', gap: 8, marginTop: 12}}>
|
||
<TouchableOpacity
|
||
style={[styles.connectButton, {flex: 1}]}
|
||
onPress={() => setVoiceCloneVisible(true)}
|
||
>
|
||
<Text style={styles.connectButtonText}>{'\uD83C\uDFA4'} Eigene Stimme aufnehmen</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
style={[styles.clearButton, {flex: 0.4, marginTop: 0}]}
|
||
onPress={() => rvs.send('xtts_list_voices' as any, {})}
|
||
>
|
||
<Text style={styles.clearButtonText}>Aktualisieren</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* === Speicher === */}
|
||
<Text style={styles.sectionTitle}>Anhang-Speicher</Text>
|
||
<View style={styles.card}>
|
||
<View style={styles.toggleRow}>
|
||
<View style={styles.toggleInfo}>
|
||
<Text style={styles.toggleLabel}>Auto-Download</Text>
|
||
<Text style={styles.toggleHint}>
|
||
Fehlende Anhaenge beim App-Start automatisch vom Server laden
|
||
</Text>
|
||
</View>
|
||
<Switch
|
||
value={autoDownload}
|
||
onValueChange={(val) => {
|
||
setAutoDownload(val);
|
||
AsyncStorage.setItem('aria_auto_download', String(val));
|
||
}}
|
||
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||
thumbColor={autoDownload ? '#FFFFFF' : '#666680'}
|
||
/>
|
||
</View>
|
||
|
||
<View style={{height: 16}} />
|
||
<Text style={styles.toggleLabel}>Lokaler Speicherort</Text>
|
||
<Text style={styles.toggleHint}>
|
||
Hier werden Bilder und Dateien aus dem Chat gespeichert.
|
||
{autoDownload ? ' Fehlende Dateien werden automatisch nachgeladen.' : ' Fehlende Dateien koennen per Tippen geladen werden.'}
|
||
</Text>
|
||
|
||
{editingPath ? (
|
||
<View style={{marginTop: 10}}>
|
||
<TextInput
|
||
style={styles.input}
|
||
value={tempPath}
|
||
onChangeText={setTempPath}
|
||
placeholder="z.B. /storage/emulated/0/ARIA/attachments"
|
||
placeholderTextColor="#555570"
|
||
autoCapitalize="none"
|
||
/>
|
||
<View style={{flexDirection: 'row', gap: 8}}>
|
||
<TouchableOpacity
|
||
style={[styles.connectButton, {flex: 1}]}
|
||
onPress={() => saveStoragePath(tempPath)}
|
||
>
|
||
<Text style={styles.connectButtonText}>Speichern</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
style={[styles.clearButton, {flex: 1, marginTop: 0}]}
|
||
onPress={() => setEditingPath(false)}
|
||
>
|
||
<Text style={styles.clearButtonText}>Abbrechen</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
) : (
|
||
<View style={{marginTop: 10}}>
|
||
<Text style={styles.storagePathText} numberOfLines={2}>{storagePath}</Text>
|
||
<Text style={styles.storageSizeText}>{storageSize}</Text>
|
||
<View style={{flexDirection: 'row', gap: 8, marginTop: 8}}>
|
||
<TouchableOpacity
|
||
style={[styles.clearButton, {flex: 1, marginTop: 0}]}
|
||
onPress={showPathPicker}
|
||
>
|
||
<Text style={styles.clearButtonText}>Pfad aendern</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
style={[styles.clearButton, {flex: 1, marginTop: 0, backgroundColor: 'rgba(255,59,48,0.15)'}]}
|
||
onPress={clearStorageCache}
|
||
>
|
||
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>Cache leeren</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* === Logs === */}
|
||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||
<View style={styles.card}>
|
||
{/* Tab-Umschalter */}
|
||
<View style={styles.tabRow}>
|
||
<TouchableOpacity
|
||
style={[styles.tab, logTab === 'live' && styles.tabActive]}
|
||
onPress={() => setLogTab('live')}
|
||
>
|
||
<Text style={[styles.tabText, logTab === 'live' && styles.tabTextActive]}>
|
||
Live Logs
|
||
</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
style={[styles.tab, logTab === 'events' && styles.tabActive]}
|
||
onPress={() => setLogTab('events')}
|
||
>
|
||
<Text style={[styles.tabText, logTab === 'events' && styles.tabTextActive]}>
|
||
Events
|
||
</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* Log-Inhalt */}
|
||
<View style={styles.logContainer}>
|
||
{logTab === 'live' ? (
|
||
logs.length > 0 ? (
|
||
logs.slice(-50).map(log => (
|
||
<View key={log.id} style={styles.logEntry}>
|
||
<Text style={styles.logTime}>{formatTime(log.timestamp)}</Text>
|
||
<Text
|
||
style={[
|
||
styles.logSource,
|
||
{ color: SOURCE_COLORS[log.source] || SOURCE_COLORS.default },
|
||
]}
|
||
>
|
||
[{log.source}]
|
||
</Text>
|
||
<Text
|
||
style={[
|
||
styles.logMessage,
|
||
log.level === 'error' && styles.logError,
|
||
log.level === 'warn' && styles.logWarn,
|
||
]}
|
||
numberOfLines={2}
|
||
>
|
||
{log.message}
|
||
</Text>
|
||
</View>
|
||
))
|
||
) : (
|
||
<Text style={styles.emptyLog}>Noch keine Logs empfangen</Text>
|
||
)
|
||
) : (
|
||
events.length > 0 ? (
|
||
events.slice(-30).map(event => (
|
||
<View key={event.id} style={styles.eventEntry}>
|
||
<Text style={styles.eventTime}>{formatTime(event.timestamp)}</Text>
|
||
<Text style={styles.eventTitle}>{event.title}</Text>
|
||
<Text style={styles.eventDescription}>{event.description}</Text>
|
||
</View>
|
||
))
|
||
) : (
|
||
<Text style={styles.emptyLog}>Noch keine Events empfangen</Text>
|
||
)
|
||
)}
|
||
</View>
|
||
|
||
{/* Log-Aktionen */}
|
||
<TouchableOpacity
|
||
style={styles.clearButton}
|
||
onPress={() => {
|
||
if (logTab === 'live') setLogs([]);
|
||
else setEvents([]);
|
||
}}
|
||
>
|
||
<Text style={styles.clearButtonText}>Protokoll l\u00F6schen</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* === About === */}
|
||
<Text style={styles.sectionTitle}>{'\u00DC'}ber</Text>
|
||
<View style={styles.card}>
|
||
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
|
||
<Text style={styles.aboutVersion}>Version {require('../../package.json').version}</Text>
|
||
<Text style={styles.aboutInfo}>
|
||
Stefans Kommandozentrale f{'\u00FC'}r ARIA.{'\n'}
|
||
Gebaut mit React Native + TypeScript.
|
||
</Text>
|
||
<TouchableOpacity
|
||
style={[styles.connectButton, {marginTop: 12}]}
|
||
onPress={() => {
|
||
const updateService = require('../services/updater').default;
|
||
updateService.checkForUpdate();
|
||
Alert.alert('Update-Check', 'Pruefe auf neue Version...');
|
||
}}
|
||
>
|
||
<Text style={styles.connectButtonText}>Auf Updates pr{'\u00FC'}fen</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* Platz am Ende */}
|
||
<View style={styles.bottomSpacer} />
|
||
</ScrollView>
|
||
</>
|
||
);
|
||
};
|
||
|
||
// --- 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,
|
||
},
|
||
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;
|