Files
pyramid/lib/widgets/mic_level_test.dart
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

215 lines
6.4 KiB
Dart
Raw Permalink 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.
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<MicLevelTest> createState() => _MicLevelTestState();
}
class _MicLevelTestState extends State<MicLevelTest> {
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<void> _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<void> _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<void> _release(MediaStream? s, RTCPeerConnection? a, RTCPeerConnection? b) async {
for (final t in s?.getTracks() ?? <MediaStreamTrack>[]) {
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,
),
),
],
);
}
}