part of '../settings_modal.dart'; class _VoiceSection extends ConsumerStatefulWidget { final PyramidTheme pt; const _VoiceSection({required this.pt}); @override ConsumerState<_VoiceSection> createState() => _VoiceSectionState(); } class _VoiceSectionState extends ConsumerState<_VoiceSection> { List _audioInputs = []; List _audioOutputs = []; List _videoInputs = []; String? _selectedMic; String? _selectedSpeaker; String? _selectedCamera; double _outputVolume = 1.0; bool _loading = true; String? _loadError; static const _kMic = 'voice_mic_device'; static const _kSpeaker = 'voice_speaker_device'; static const _kCamera = 'voice_camera_device'; static const _kOutputVolume = 'voice_output_volume'; @override void initState() { super.initState(); _init(); } Future _init() async { final prefs = await SharedPreferences.getInstance(); List devices = []; String? loadError; try { devices = await navigator.mediaDevices.enumerateDevices(); } catch (_) { devices = []; } // Windows (und teils Android) liefert eine LEERE Liste ohne Fehler, // solange in diesem Prozess noch nie ein Media-Stream geöffnet wurde. // Deshalb nicht nur bei Exception, sondern auch bei leerer Liste einmal // kurz getUserMedia anfordern und erneut enumerieren — und zwar SOLANGE // der Stream noch offen ist (nach stop() melden manche Systeme wieder 0). final hasAudioInput = devices.any((d) => d.kind?.toLowerCase() == 'audioinput'); if (devices.isEmpty || !hasAudioInput) { try { final stream = await navigator.mediaDevices .getUserMedia({'audio': true, 'video': false}); try { devices = await navigator.mediaDevices.enumerateDevices(); } finally { for (final t in stream.getTracks()) { t.stop(); } await stream.dispose(); } if (devices.isEmpty) { // Bekanntes libwebrtc-Problem auf manchen Windows-Systemen: das // Audio-Device-Module meldet 0 Geräte, obwohl Aufnahme/Wiedergabe // über das Standardgerät einwandfrei funktionieren. loadError = 'Die Audiotreiber melden keine Geräteliste an WebRTC ' '(bekanntes libwebrtc-Problem auf manchen Windows-Systemen). ' 'Anrufe funktionieren trotzdem — es wird das Windows-Standardgerät verwendet.'; } } catch (e2) { loadError = 'getUserMedia: ${e2.toString().split('\n').first}'; } } if (!mounted) return; setState(() { _audioInputs = devices.where((d) => d.kind?.toLowerCase() == 'audioinput').toList(); _audioOutputs = devices.where((d) => d.kind?.toLowerCase() == 'audiooutput').toList(); _videoInputs = devices.where((d) => d.kind?.toLowerCase() == 'videoinput').toList(); _selectedMic = prefs.getString(_kMic); _selectedSpeaker = prefs.getString(_kSpeaker); _selectedCamera = prefs.getString(_kCamera); _outputVolume = prefs.getDouble(_kOutputVolume) ?? 1.0; _loadError = loadError; _loading = false; }); } Future _setOutputVolume(double v) async { setState(() => _outputVolume = v); final prefs = await SharedPreferences.getInstance(); await prefs.setDouble(_kOutputVolume, v); // Live auf laufende Calls anwenden (über die Modul-Fassaden). ref.read(voiceChannelProvider).setOutputVolume(v).catchError((_) {}); try { ref.read(callSignalingProvider).applyOutputVolume(v).catchError((_) {}); } catch (_) {} // Instanz existiert evtl. noch nicht } Future _save(String key, String? value) async { final prefs = await SharedPreferences.getInstance(); if (value == null || value.isEmpty) { await prefs.remove(key); } else { await prefs.setString(key, value); } } Widget _deviceDropdown({ required List devices, required String? selected, required String placeholder, required IconData icon, required ValueChanged onChanged, }) { final pt = widget.pt; if (devices.isEmpty) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(6), border: Border.all(color: pt.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 13, color: pt.fgDim), const SizedBox(width: 6), Text('Kein Gerät', style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ), ); } final validSelected = devices.any((d) => d.deviceId == selected) ? selected : null; return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 220), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(6), border: Border.all(color: pt.border), ), child: DropdownButtonHideUnderline( child: DropdownButton( value: validSelected, hint: Row(children: [ Icon(icon, size: 13, color: pt.fgDim), const SizedBox(width: 6), Text(placeholder, style: TextStyle(color: pt.fgDim, fontSize: 12)), ]), dropdownColor: pt.bg2, iconEnabledColor: pt.fgDim, isDense: true, style: TextStyle(color: pt.fg, fontSize: 12), onChanged: onChanged, items: devices.map((d) { final label = d.label.isNotEmpty ? d.label : 'Gerät ${d.deviceId.substring(0, 6)}'; return DropdownMenuItem( value: d.deviceId, child: Text(label, overflow: TextOverflow.ellipsis), ); }).toList(), ), ), ), ); } @override Widget build(BuildContext context) { final pt = widget.pt; return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle( title: 'Sprache & Video', subtitle: 'Mikrofon, Kamera und Anrufeinstellungen.', pt: pt), if (_loading) const Center(child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive())) else ...[ if (_loadError != null) Padding( padding: const EdgeInsets.only(bottom: 16), child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: pt.bg2, borderRadius: BorderRadius.circular(8), border: Border.all(color: pt.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(Icons.warning_amber_rounded, size: 16, color: pt.away), const SizedBox(width: 8), Text('Geräteliste nicht verfügbar', style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), ]), const SizedBox(height: 6), Text(_loadError!, style: TextStyle(color: pt.fgDim, fontSize: 11)), const SizedBox(height: 6), Text( 'Mikrofon und Lautsprecher legst du in diesem Fall über die ' 'Windows-Soundeinstellungen (Standardgerät) fest. ' 'Falls dort alles stimmt: Windows-Einstellungen → Datenschutz → Mikrofon prüfen.', style: TextStyle(color: pt.fgMuted, fontSize: 11), ), ], ), ), ), _SettingsGroup(title: 'Geräte', pt: pt, children: [ _SettingsRow( title: 'Mikrofon', desc: _audioInputs.isEmpty ? 'Kein Gerät gefunden' : '${_audioInputs.length} Gerät(e) gefunden', pt: pt, control: _deviceDropdown( devices: _audioInputs, selected: _selectedMic, placeholder: 'Standard', icon: Icons.mic_rounded, onChanged: (v) { setState(() => _selectedMic = v); _save(_kMic, v); }, ), ), _Divider(pt: pt), _SettingsRow( title: 'Lautsprecher', desc: _audioOutputs.isEmpty ? 'Kein Gerät gefunden' : '${_audioOutputs.length} Gerät(e) gefunden', pt: pt, control: _deviceDropdown( devices: _audioOutputs, selected: _selectedSpeaker, placeholder: 'Standard', icon: Icons.volume_up_rounded, onChanged: (v) { setState(() => _selectedSpeaker = v); _save(_kSpeaker, v); }, ), ), _Divider(pt: pt), _SettingsRow( title: 'Kamera', desc: _videoInputs.isEmpty ? 'Kein Gerät gefunden' : '${_videoInputs.length} Gerät(e) gefunden', pt: pt, control: _deviceDropdown( devices: _videoInputs, selected: _selectedCamera, placeholder: 'Standard', icon: Icons.videocam_rounded, onChanged: (v) { setState(() => _selectedCamera = v); _save(_kCamera, v); }, ), ), ]), const SizedBox(height: 16), _SettingsGroup(title: 'Pegel', pt: pt, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.volume_up_rounded, size: 15, color: pt.fgMuted), const SizedBox(width: 8), Text('Ausgabelautstärke', style: TextStyle( color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), const Spacer(), Text('${(_outputVolume * 100).round()} %', style: TextStyle( color: pt.fgMuted, fontSize: 12, fontFamily: 'monospace')), ], ), SliderTheme( data: SliderThemeData( activeTrackColor: pt.accent, inactiveTrackColor: pt.bg3, thumbColor: pt.accent, overlayColor: pt.accent.withAlpha(30), trackHeight: 3, thumbShape: const RoundSliderThumbShape( enabledThumbRadius: 7), ), child: Slider( value: _outputVolume.clamp(0.0, 1.5), min: 0.0, max: 1.5, divisions: 30, onChanged: _setOutputVolume, ), ), Text( 'Lautstärke der anderen Teilnehmer in Calls. ' 'Wirkt sofort auf laufende Anrufe.', style: TextStyle(color: pt.fgDim, fontSize: 11), ), ], ), ), ]), const SizedBox(height: 16), _TurnUsageCard(pt: pt), const SizedBox(height: 12), TextButton.icon( style: TextButton.styleFrom( foregroundColor: pt.fgMuted, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), onPressed: () { setState(() { _loading = true; _loadError = null; }); _init(); }, icon: const Icon(Icons.refresh_rounded, size: 14), label: const Text('Geräte neu laden', style: TextStyle(fontSize: 12)), ), ], ], ), ); } } /// Zeigt den TURN-Relay-Verbrauch des laufenden Monats an. Die Daten kommen /// aus der turn-status.json, die der Guard-Cron auf dem Pi stündlich schreibt. /// Solange der Endpoint nicht existiert (Analytics-Token fehlt), bleibt die /// Karte unsichtbar. class _TurnUsageCard extends StatefulWidget { final PyramidTheme pt; const _TurnUsageCard({required this.pt}); @override State<_TurnUsageCard> createState() => _TurnUsageCardState(); } class _TurnUsageCardState extends State<_TurnUsageCard> { Map? _status; @override void initState() { super.initState(); _load(); } Future _load() async { try { final resp = await http .get(Uri.parse( 'https://steggi-matrix.work/.well-known/pyramid/turn-status.json')) .timeout(const Duration(seconds: 5)); if (resp.statusCode == 200 && mounted) { setState( () => _status = jsonDecode(resp.body) as Map); } } catch (_) { // Endpoint (noch) nicht vorhanden — Karte einfach nicht anzeigen. } } @override Widget build(BuildContext context) { final s = _status; if (s == null) return const SizedBox.shrink(); final pt = widget.pt; final used = (s['egress_gb'] as num?)?.toDouble() ?? 0; final limit = (s['limit_gb'] as num?)?.toDouble() ?? 990; final pct = limit > 0 ? (used / limit).clamp(0.0, 1.0) : 0.0; final active = s['turn_active'] != false; final barColor = !active ? pt.danger : pct > 0.8 ? pt.away : pt.accent; return _SettingsGroup(title: 'Call-Relay (TURN)', pt: pt, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(active ? Icons.cloud_done_rounded : Icons.cloud_off_rounded, size: 15, color: active ? pt.online : pt.danger), const SizedBox(width: 8), Text( active ? 'Relay aktiv' : 'Relay deaktiviert (Limit erreicht)', style: TextStyle( color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), const Spacer(), Text( '${used.toStringAsFixed(1)} / ${limit.toStringAsFixed(0)} GB', style: TextStyle( color: pt.fgMuted, fontSize: 12, fontFamily: 'monospace')), ]), const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(99), child: LinearProgressIndicator( value: pct, minHeight: 5, backgroundColor: pt.bg3, color: barColor, ), ), const SizedBox(height: 6), Text( active ? 'Cloudflare-TURN-Traffic diesen Monat (Free Tier: ${limit.toStringAsFixed(0)} GB). ' 'Bei Erreichen des Limits laufen Calls bis Monatsende ohne Relay weiter.' : 'Monatslimit erreicht — Calls nutzen bis Monatsende nur direkte Verbindungen (STUN).', style: TextStyle(color: pt.fgDim, fontSize: 11), ), ], ), ), ]); } }