442 lines
16 KiB
Dart
442 lines
16 KiB
Dart
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<MediaDeviceInfo> _audioInputs = [];
|
||
List<MediaDeviceInfo> _audioOutputs = [];
|
||
List<MediaDeviceInfo> _videoInputs = [];
|
||
String? _selectedMic;
|
||
String? _selectedSpeaker;
|
||
String? _selectedCamera;
|
||
double _outputVolume = 1.0;
|
||
bool _loading = true;
|
||
String? _loadError;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_init();
|
||
}
|
||
|
||
Future<void> _init() async {
|
||
final devicePrefs = await VoicePrefs.loadDevices();
|
||
final outputVolume = await VoicePrefs.loadOutputVolume();
|
||
List<MediaDeviceInfo> devices = [];
|
||
String? loadError;
|
||
try {
|
||
devices = await navigator.mediaDevices.enumerateDevices();
|
||
} catch (_) {
|
||
devices = [];
|
||
}
|
||
// Windows: die Geräteliste kommt aus dem Audio-Device-Module der
|
||
// libwebrtc.dll, und das wird erst initialisiert, wenn irgendeine
|
||
// PeerConnection existiert (webrtc-sdk/libwebrtc Issue #141, Fix #142
|
||
// noch in keinem Binary-Release – Diagnose siehe tool/device_probe.dart).
|
||
// Ohne laufenden Call ist die Liste deshalb IMMER leer. Workaround:
|
||
// kurz eine Dummy-PeerConnection anlegen (kein Mikrofonzugriff nötig),
|
||
// enumerieren, wieder schließen.
|
||
var hasAudioInput =
|
||
devices.any((d) => d.kind?.toLowerCase() == 'audioinput');
|
||
if (devices.isEmpty || !hasAudioInput) {
|
||
RTCPeerConnection? admWakeup;
|
||
try {
|
||
admWakeup = await createPeerConnection({});
|
||
devices = await navigator.mediaDevices.enumerateDevices();
|
||
} catch (_) {
|
||
// Fallback unten übernimmt.
|
||
} finally {
|
||
await admWakeup?.close();
|
||
}
|
||
}
|
||
// Zweite Stufe (andere Systeme, z. B. teils Android): einmal kurz
|
||
// getUserMedia anfordern und erneut enumerieren, SOLANGE der Stream
|
||
// offen ist (nach stop() melden manche Systeme wieder 0).
|
||
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 = devicePrefs.mic;
|
||
_selectedSpeaker = devicePrefs.speaker;
|
||
_selectedCamera = devicePrefs.camera;
|
||
_outputVolume = outputVolume;
|
||
_loadError = loadError;
|
||
_loading = false;
|
||
});
|
||
}
|
||
|
||
Future<void> _setOutputVolume(double v) async {
|
||
setState(() => _outputVolume = v);
|
||
await VoicePrefs.saveOutputVolume(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
|
||
}
|
||
|
||
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);
|
||
VoicePrefs.saveMic(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);
|
||
VoicePrefs.saveSpeaker(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);
|
||
VoicePrefs.saveCamera(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<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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
]);
|
||
}
|
||
}
|