Files
pyramid/lib/widgets/settings/settings_voice.dart
T
Bernd SteckmeisterandClaude Opus 5.5 441cc6ef54 fix(calls): Bildschirmübertragung ohne Absturz, Lautsprecher, Mikrofon im Hintergrund, zurück in den Anruf
Bernds Anruftest Handy↔PC (2026-10-07):
- Teilen am Handy: App "verkleinerte sich", Anruf danach weg. Ursache:
  Android 14+ verlangt vor MediaProjection einen Vordergrunddienst vom Typ
  mediaProjection; flutter_webrtc startet keinen, der Manifest-Eintrag
  verwies auf eine nicht mehr existierende Klasse. Neu: CallService
  (microphone|mediaProjection) + CallForeground: Zustimmung → Dienst
  (wartet auf echten startForeground) → Mitschnitt. Emulator-Test (Android
  16): mit Fix 1080x2400@30 läuft; Gegenprobe ohne Dienst wirft genau
  'Media projections require a foreground service ...'.
- Dienst läuft während jedes Anrufs: Mikrofon bleibt im Hintergrund aktiv,
  Benachrichtigung "Anruf läuft" führt zurück. Mikrofon-Typ nur mit
  Berechtigung, sonst schmalere Variante statt Dienst-Abbruch.
- Handy hörte nichts: SDK schaltet Sprachanrufe auf die Hörmuschel. Jetzt
  Lautsprecher als Standard (wie Discord) + Umschalter in der Anrufleiste.
- LiveKit-Teilen am Handy öffnete den PC-Quellen-Auswähler → System-Dialog.
- Im offenen Chat (Handy) Leiste "Anruf läuft · Zurück".
- Einstellungen → Sprache & Video: Mikrofontest mit Pegel (PC-Diagnose:
  das Standardgerät lieferte nur Stille – Voicemeeter/virtuelle Geräte).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 00:01:29 +02:00

458 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: Builder(builder: (context) {
final voip = ref.watch(callSignalingProvider);
final lk = ref.watch(voiceChannelProvider);
final inCall = voip.currentCall != null || lk.isActive;
// Schlüssel je gewähltem Mikrofon: Gerätewechsel beendet
// einen laufenden Test (sonst misst er das alte Gerät).
return MicLevelTest(
key: ValueKey('mictest:$_selectedMic'),
pt: pt,
enabled: !inCall,
);
}),
),
_Divider(pt: pt),
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),
),
],
),
),
]);
}
}