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>
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user