import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/theme.dart'; /// Mikrofontest mit Pegelanzeige (Einstellungen → Sprache & Video). /// /// Nimmt über DENSELBEN Weg wie ein Anruf auf (gewähltes Gerät aus den /// Einstellungen, WebRTC) und zeigt den Pegel live. Anlass: Bernds Test /// 2026-10-07 – das Handy hörte nichts vom PC, weil (mit Voicemeeter und /// vielen virtuellen Geräten) das Standardgerät nur Stille lieferte. Ohne /// Pegelanzeige war das nicht zu erkennen. /// /// Technik: lokale Verbindung zu sich selbst (zwei PeerConnections im selben /// Prozess) – erst dann startet libwebrtc die Aufnahme und liefert den Pegel /// in den Statistiken. Es wird nichts verschickt oder gespeichert. Nach /// 30 Sekunden endet der Test von selbst. class MicLevelTest extends StatefulWidget { const MicLevelTest({super.key, required this.pt, this.enabled = true}); final PyramidTheme pt; /// false z. B. während eines Anrufs (zwei Aufnahmen gleichzeitig vermeiden). final bool enabled; @override State createState() => _MicLevelTestState(); } class _MicLevelTestState extends State { MediaStream? _stream; RTCPeerConnection? _a; RTCPeerConnection? _b; Timer? _poll; Timer? _autoStop; bool _running = false; bool _starting = false; double _level = 0; // 0..1 (geglättet) double _peak = 0; String? _error; @override void dispose() { _stop(notify: false); super.dispose(); } Future _start() async { setState(() { _starting = true; _error = null; _peak = 0; _level = 0; }); try { final mic = (await VoicePrefs.loadDevices()).mic; final audio = mic == null ? true : { 'deviceId': mic, 'optional': [ {'sourceId': mic}, ], }; final stream = await navigator.mediaDevices .getUserMedia({'audio': audio, 'video': false}); final a = await createPeerConnection({}); final b = await createPeerConnection({}); a.onIceCandidate = (c) => b.addCandidate(c); b.onIceCandidate = (c) => a.addCandidate(c); await a.addTrack(stream.getAudioTracks().first, stream); final offer = await a.createOffer(); await a.setLocalDescription(offer); await b.setRemoteDescription(offer); final answer = await b.createAnswer(); await b.setLocalDescription(answer); await a.setRemoteDescription(answer); if (!mounted) { await _release(stream, a, b); return; } _stream = stream; _a = a; _b = b; _poll = Timer.periodic(const Duration(milliseconds: 120), (_) => _read()); _autoStop = Timer(const Duration(seconds: 30), _stop); setState(() { _running = true; _starting = false; }); } catch (e) { if (mounted) { setState(() { _starting = false; _error = 'Mikrofon ließ sich nicht öffnen: ${e.toString().split('\n').first}'; }); } } } Future _read() async { final a = _a; if (a == null) return; try { for (final r in await a.getStats()) { if (r.type != 'media-source') continue; final raw = (r.values['audioLevel'] as num?)?.toDouble() ?? 0; // Wurzel-Skala: leise Sprache soll sichtbar ausschlagen. final shown = math.sqrt(raw.clamp(0.0, 1.0)); if (!mounted) return; setState(() { _level = _level * 0.4 + shown * 0.6; _peak = math.max(_peak, shown); }); } } catch (_) {} } Future _release(MediaStream? s, RTCPeerConnection? a, RTCPeerConnection? b) async { for (final t in s?.getTracks() ?? []) { try { await t.stop(); } catch (_) {} } try { await s?.dispose(); } catch (_) {} try { await a?.close(); } catch (_) {} try { await b?.close(); } catch (_) {} } void _stop({bool notify = true}) { _poll?.cancel(); _autoStop?.cancel(); _poll = null; _autoStop = null; final s = _stream, a = _a, b = _b; _stream = null; _a = null; _b = null; _release(s, a, b); if (notify && mounted) { setState(() { _running = false; _level = 0; }); } else { _running = false; } } @override Widget build(BuildContext context) { final pt = widget.pt; final silent = _running && _peak < 0.02; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.mic_rounded, size: 15, color: pt.fgMuted), const SizedBox(width: 8), Text('Mikrofontest', style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), const Spacer(), TextButton( onPressed: !widget.enabled || _starting ? null : (_running ? _stop : _start), child: Text(_running ? 'Beenden' : 'Testen'), ), ], ), const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(4), child: Container( height: 8, color: pt.bg3, alignment: Alignment.centerLeft, child: AnimatedFractionallySizedBox( duration: const Duration(milliseconds: 110), widthFactor: _level.clamp(0.0, 1.0), heightFactor: 1, child: Container(color: _level > 0.85 ? pt.danger : pt.accent), ), ), ), const SizedBox(height: 6), Text( !widget.enabled ? 'Während eines Anrufs nicht möglich.' : _error ?? (_running ? (silent ? 'Kein Ton zu hören – sprich etwas. Bleibt der Balken leer, oben ein anderes Mikrofon wählen.' : 'Sprich etwas – der Balken zeigt, was die anderen hören würden.') : 'Prüft das oben gewählte Mikrofon. Es wird nichts gesendet oder gespeichert.'), style: TextStyle( color: _error != null || silent ? pt.away : pt.fgDim, fontSize: 11, ), ), ], ); } }