3c7eb9ddca
- CallSignalingService/callSignalingProvider und VoiceChannelService/ voiceChannelProvider als einzige oeffentliche Schnittstellen - voip_manager.dart / livekit_call_manager.dart in ihre Module verschoben, Logik unangetastet (nur Klassen-Koepfe, @override, Imports, Provider-Namen) - Alle Konsumenten entkoppelt; Kompositions-Punkt matrix_client.dart als dokumentierte Ausnahme. analyze sauber, 29 Tests gruen, Windows-Start ok. Call-Klick-/Geraetetest steht aus (PROGRESS.md)
438 lines
16 KiB
Dart
438 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;
|
|
|
|
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 (über die Modul-Fassaden).
|
|
ref.read(voiceChannelProvider).setOutputVolume(v).catchError((_) {});
|
|
try {
|
|
ref.read(callSignalingProvider).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)),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
]);
|
|
}
|
|
}
|