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]>
This commit is contained in:
Bernd Steckmeister
2026-10-08 00:01:29 +02:00
co-authored by Claude Opus 5.5
parent c817de924a
commit 441cc6ef54
18 changed files with 750 additions and 3 deletions
+214
View File
@@ -0,0 +1,214 @@
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,
),
),
],
);
}
}