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]>
215 lines
6.4 KiB
Dart
215 lines
6.4 KiB
Dart
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,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|