Files
pyramid/lib/widgets/settings/settings_voice.dart
T
Bernd Steckmeister 5d772389d6 refactor: Gott-Datei settings_modal.dart in 12 Dateien aufgeteilt
Reine Verschiebung per Dart part/part-of (keine Umbenennung, keine
Logikänderung) - settings_modal.dart schrumpft von 5541 auf 270 Zeilen.
Verifiziert per automatisiertem Blockvergleich gegen das Original und
flutter analyze (Baseline unverändert: 1 bekannter Hinweis).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 10:56:48 +02:00

434 lines
16 KiB
Dart

part of '../settings_modal.dart';
class _VoiceSection extends StatefulWidget {
final PyramidTheme pt;
const _VoiceSection({required this.pt});
@override
State<_VoiceSection> createState() => _VoiceSectionState();
}
class _VoiceSectionState extends State<_VoiceSection> {
List<MediaDeviceInfo> _audioInputs = [];
List<MediaDeviceInfo> _audioOutputs = [];
List<MediaDeviceInfo> _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<void> _init() async {
final prefs = await SharedPreferences.getInstance();
List<MediaDeviceInfo> 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<void> _setOutputVolume(double v) async {
setState(() => _outputVolume = v);
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_kOutputVolume, v);
// Live auf laufende Calls anwenden.
LiveKitCallManager.instance.setOutputVolume(v).catchError((_) {});
try {
PyramidVoipManager.instance.applyOutputVolume(v).catchError((_) {});
} catch (_) {} // Instanz existiert evtl. noch nicht
}
Future<void> _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<MediaDeviceInfo> devices,
required String? selected,
required String placeholder,
required IconData icon,
required ValueChanged<String?> 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<String?>(
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<String?>(
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)),
),
],
],
),
);
}
}
class _TurnUsageCard extends StatefulWidget {
final PyramidTheme pt;
const _TurnUsageCard({required this.pt});
@override
State<_TurnUsageCard> createState() => _TurnUsageCardState();
}
class _TurnUsageCardState extends State<_TurnUsageCard> {
Map<String, dynamic>? _status;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<String, dynamic>);
}
} 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),
),
],
),
),
]);
}
}