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
+84
View File
@@ -0,0 +1,84 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
/// Anruf-Vordergrunddienst auf Android (android/.../CallService.kt).
///
/// * Solange ein Anruf läuft, hält der Dienst das Mikrofon auch im
/// Hintergrund aktiv (Android schaltet es sonst stumm) und zeigt eine
/// „Anruf läuft“-Benachrichtigung, über die man zurück in die App kommt.
/// * Für eine Bildschirmübertragung MUSS ab Android 14 vorher ein Dienst vom
/// Typ mediaProjection laufen – und der darf erst NACH der Zustimmung des
/// Nutzers starten. [prepareScreenCapture] erledigt beides in der richtigen
/// Reihenfolge; flutter_webrtc benutzt danach die gespeicherte Zustimmung.
///
/// Beide Anrufwege (1:1 über Matrix, Sprachkanäle über LiveKit) melden sich
/// mit eigenem Namen an; der Dienst endet erst, wenn keiner mehr läuft.
/// Auf anderen Plattformen passiert nichts.
class CallForeground {
CallForeground._();
static const _channel = MethodChannel('chat.pyramid.pyramid/call');
static final _owners = <String>{};
static final _screenOwners = <String>{};
static String _title = 'Pyramid';
static bool get _android => !kIsWeb && Platform.isAndroid;
/// true = Dienst läuft (bzw. wurde beendet) wie gewünscht.
static Future<bool> _apply() async {
if (!_android) return true;
try {
if (_owners.isEmpty) {
await _channel.invokeMethod('stop');
return true;
}
final ok = await _channel.invokeMethod<bool>('start', {
'title': _title,
'screen': _screenOwners.isNotEmpty,
});
return ok ?? false;
} catch (e) {
debugPrint('[CallForeground] $e');
return false;
}
}
/// Anruf von [owner] läuft (z. B. 'voip', 'livekit').
static Future<void> callStarted(String owner, {required String title}) async {
_owners.add(owner);
_title = title;
await _apply();
}
/// Anruf von [owner] ist beendet.
static Future<void> callEnded(String owner) async {
final changed = _owners.remove(owner) | _screenOwners.remove(owner);
if (changed) await _apply();
}
/// Vor einer Bildschirmübertragung auf Android: Zustimmung einholen, dann
/// den Dienst um „mediaProjection“ erweitern. false = Nutzer hat abgelehnt.
static Future<bool> prepareScreenCapture(String owner) async {
if (!_android) return true;
final granted = await rtc.Helper.requestCapturePermission();
if (!granted) return false;
_owners.add(owner);
_screenOwners.add(owner);
if (!await _apply()) {
// Ohne laufenden mediaProjection-Dienst würde der Mitschnitt die App
// abstürzen lassen – dann lieber gar nicht übertragen.
_screenOwners.remove(owner);
await _apply();
return false;
}
return true;
}
/// Übertragung von [owner] beendet – Dienst wieder ohne mediaProjection.
static Future<void> screenCaptureStopped(String owner) async {
if (_screenOwners.remove(owner)) await _apply();
}
}
@@ -49,6 +49,11 @@ abstract class CallSignalingService extends ChangeNotifier {
Future<void> changeQuality(String key);
/// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
/// Auf dem PC ohne Bedeutung.
bool get isSpeakerOn => true;
Future<void> toggleSpeaker() async {}
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
Future<void> applyOutputVolume([double? value]);
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:matrix/matrix.dart';
import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:pyramid/core/call_foreground.dart';
import 'package:pyramid/core/ice_servers.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
@@ -35,6 +36,34 @@ class PyramidVoipManager extends CallSignalingService
bool _isDeafened = false;
bool _isUpdating = false;
String? _selectedSourceId;
// Handy: Lautsprecher statt Hörmuschel (wie Discord). Das SDK schaltet bei
// Sprachanrufen auf die Hörmuschel – hält man das Handy vor sich, hört man
// dann praktisch nichts (Bernds Test 2026-10-07).
bool _speakerOn = true;
bool get _isMobile =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
@override
bool get isSpeakerOn => _speakerOn;
@override
Future<void> toggleSpeaker() async {
_speakerOn = !_speakerOn;
notifyListeners();
await _applySpeaker();
}
Future<void> _applySpeaker() async {
if (!_isMobile) return;
try {
await rtc.Helper.setSpeakerphoneOn(_speakerOn);
} catch (e) {
debugPrint('[VOIP] Lautsprecher: $e');
}
}
String _currentQualityKey = 'hd';
@override
@@ -104,6 +133,7 @@ class PyramidVoipManager extends CallSignalingService
await _ensureRenderers();
currentCall = call;
_isCameraMuted = call.type == CallType.kVoice;
_speakerOn = true;
_setupCallListeners(call);
_updateInternalStates();
notifyListeners();
@@ -120,9 +150,19 @@ class PyramidVoipManager extends CallSignalingService
debugPrint('[VOIP] Ensuring camera is unmuted on connect');
call.setLocalVideoMuted(false);
}
_applySpeaker();
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
CallForeground.callStarted('voip',
title: call.room.getLocalizedDisplayname());
}
if (state == CallState.kEnded) CallForeground.callEnded('voip');
_updateRenderers();
});
// Das SDK setzt bei jedem neuen eigenen Medienstrom die Hörmuschel –
// danach unsere Wahl erneut anwenden.
call.onCallEventChanged.stream.listen((event) {
if (event == CallStateChange.kFeedsChanged) _applySpeaker();
});
_renderRefreshTimer?.cancel();
_renderRefreshTimer = Timer.periodic(
@@ -259,6 +299,7 @@ class PyramidVoipManager extends CallSignalingService
_isRemoteScreensharing = false;
notifyListeners();
}
CallForeground.callEnded('voip');
}
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
@@ -292,6 +333,7 @@ class PyramidVoipManager extends CallSignalingService
currentCall = null;
notifyListeners();
}
CallForeground.callEnded('voip');
}
@override
@@ -361,6 +403,7 @@ class PyramidVoipManager extends CallSignalingService
if (_isScreensharing) {
await safeAction(() => call.setScreensharingEnabled(false));
await CallForeground.screenCaptureStopped('voip');
return;
}
@@ -390,6 +433,7 @@ class PyramidVoipManager extends CallSignalingService
await currentCall!.hangup(reason: CallErrorCode.userHangup);
currentCall = null;
});
await CallForeground.callEnded('voip');
}
@override
@@ -399,6 +443,7 @@ class PyramidVoipManager extends CallSignalingService
await _ensureRenderers();
_isCameraMuted = !video;
_speakerOn = true;
debugPrint('[VOIP] Starting call in room $roomId (video=$video)');
final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice);
currentCall = call;
@@ -525,6 +570,13 @@ class MediaDevicesWrapper extends MediaDevices {
@override
Future<rtc.MediaStream> getDisplayMedia(Map<String, dynamic> constraints) async {
// Android 14+: ohne laufenden mediaProjection-Dienst stürzt die App beim
// Mitschnitt ab – erst Zustimmung, dann Dienst, dann Mitschnitt.
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
if (!await CallForeground.prepareScreenCapture('voip')) {
throw Exception('Bildschirmübertragung wurde nicht erlaubt');
}
}
final String? sourceId = _manager._selectedSourceId;
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) {
final desktopConstraints = {
@@ -51,6 +51,9 @@ class _CallControls extends StatelessWidget {
final VoidCallback onToggleScreen;
final VoidCallback onHangUp;
final PyramidTheme pt;
// Handy: Lautsprecher/Hörmuschel statt „Ton aus“ (Platz in der Leiste).
final bool isSpeakerOn;
final VoidCallback? onToggleSpeaker;
const _CallControls({
required this.isMuted,
@@ -63,10 +66,15 @@ class _CallControls extends StatelessWidget {
required this.onToggleScreen,
required this.onHangUp,
required this.pt,
this.isSpeakerOn = true,
this.onToggleSpeaker,
});
@override
Widget build(BuildContext context) {
final platform = Theme.of(context).platform;
final showSpeaker = onToggleSpeaker != null &&
(platform == TargetPlatform.android || platform == TargetPlatform.iOS);
return SafeArea(
top: false,
child: Container(
@@ -77,7 +85,15 @@ class _CallControls extends StatelessWidget {
children: [
_VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic),
const SizedBox(width: 16),
_VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen),
if (showSpeaker)
_VoiceCtrlBtn(
icon: isSpeakerOn ? Icons.volume_up_rounded : Icons.phone_in_talk_rounded,
active: isSpeakerOn,
pt: pt,
onTap: onToggleSpeaker!,
)
else
_VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen),
const SizedBox(width: 16),
_VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam),
const SizedBox(width: 16),
+6
View File
@@ -17,6 +17,8 @@ class _UnifiedCallView extends StatefulWidget {
final bool isScreenSharing;
final Function(Offset)? onQualityMenu;
final PyramidTheme pt;
final bool isSpeakerOn;
final VoidCallback? onToggleSpeaker;
const _UnifiedCallView({
required this.participants,
@@ -35,6 +37,8 @@ class _UnifiedCallView extends StatefulWidget {
required this.isScreenSharing,
this.onQualityMenu,
required this.pt,
this.isSpeakerOn = true,
this.onToggleSpeaker,
});
@override
@@ -148,6 +152,8 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
onToggleScreen: widget.onToggleScreen,
onHangUp: widget.onHangUp,
pt: pt,
isSpeakerOn: widget.isSpeakerOn,
onToggleSpeaker: widget.onToggleSpeaker,
),
],
),
@@ -3,6 +3,11 @@ part of '../voice_channel.dart';
void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async {
if (call.isScreenSharing) {
await call.stopScreenShare();
} else if (Theme.of(context).platform == TargetPlatform.android ||
Theme.of(context).platform == TargetPlatform.iOS) {
// Handy: ganzer Bildschirm über die System-Zustimmung (kein
// Fenster-Auswähler wie am PC).
await call.toggleScreenShare();
} else {
final source = await ScreenSharePicker.show(context);
if (source != null) await call.startScreenShare(source.id);
@@ -58,6 +58,8 @@ class VoiceChannelView extends ConsumerWidget {
isScreenSharing: voip.isScreensharing,
onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos),
pt: pt,
isSpeakerOn: voip.isSpeakerOn,
onToggleSpeaker: voip.toggleSpeaker,
);
}
@@ -86,6 +88,8 @@ class VoiceChannelView extends ConsumerWidget {
isScreenSharing: call.isScreenSharing,
onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos),
pt: pt,
isSpeakerOn: call.isSpeakerOn,
onToggleSpeaker: call.toggleSpeaker,
);
}
+65
View File
@@ -290,3 +290,68 @@ class _InvitePanelState extends ConsumerState<_InvitePanel> {
);
}
}
/// Handy: Läuft ein Anruf, während ein Chat offen ist, zeigt diese Leiste den
/// Weg zurück (am PC übernimmt das die Anrufleiste in der Seitenleiste).
/// Vorher war im Chat nirgends zu sehen, dass/wie man zurück in den Anruf
/// kommt (Bernd 2026-10-07).
class _ActiveCallBar extends ConsumerWidget {
const _ActiveCallBar();
@override
Widget build(BuildContext context, WidgetRef ref) {
if (!isMobileLayout(MediaQuery.sizeOf(context))) return const SizedBox.shrink();
final voip = ref.watch(callSignalingProvider);
final lk = ref.watch(voiceChannelProvider);
final voipCall = voip.currentCall;
final String? name;
if (voipCall != null && voipCall.state != CallState.kEnded) {
name = voipCall.room.getLocalizedDisplayname();
} else if (lk.isActive || lk.isConnecting) {
name = lk.displayName;
} else {
name = null;
}
final pt = PyramidTheme.of(context);
return AnimatedSize(
duration: motionDuration(context, 220),
curve: Curves.easeOutCubic,
child: name == null
? const SizedBox(width: double.infinity)
: Material(
color: pt.online.withValues(alpha: 0.16),
child: InkWell(
onTap: () =>
ref.read(viewModeProvider.notifier).state = ViewMode.voice,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Icon(Icons.call_rounded, size: 18, color: pt.online),
const SizedBox(width: 10),
Expanded(
child: Text(
'Anruf läuft · $name',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: pt.fg,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
Text('Zurück',
style: TextStyle(
color: pt.online,
fontSize: 13.5,
fontWeight: FontWeight.w700)),
Icon(Icons.chevron_right_rounded, size: 20, color: pt.online),
],
),
),
),
),
);
}
}
@@ -520,6 +520,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
setState(() => _pinnedOpen = !_pinnedOpen),
),
const _ConnectionBanner(),
const _ActiveCallBar(),
Expanded(
child: isInvite
? _InvitePanel(room: room!, pt: pt)
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:livekit_client/livekit_client.dart';
import 'package:matrix/matrix.dart' hide Room;
import 'package:pyramid/core/call_foreground.dart';
import 'package:pyramid/core/ice_servers.dart';
import 'package:pyramid/core/livekit_token.dart';
import 'package:pyramid/core/settings_prefs.dart';
@@ -98,8 +99,35 @@ class LiveKitCallManager extends VoiceChannelService {
void _onRoomChanged() => notifyListeners();
// Handy: Lautsprecher statt Hörmuschel (wie Discord).
bool _speakerOn = true;
bool get _isMobile =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
@override
bool get isSpeakerOn => _speakerOn;
@override
Future<void> toggleSpeaker() async {
_speakerOn = !_speakerOn;
notifyListeners();
await _applySpeaker();
}
Future<void> _applySpeaker() async {
if (!_isMobile) return;
try {
await rtc.Helper.setSpeakerphoneOn(_speakerOn);
} catch (e) {
debugPrint('[LiveKit] Lautsprecher: $e');
}
}
void _cleanUpAfterDisconnect() {
_callGeneration++;
CallForeground.callEnded('livekit');
_stopStatsLogging();
_presenceHeartbeat?.cancel();
_presenceHeartbeat = null;
@@ -254,6 +282,10 @@ class LiveKitCallManager extends VoiceChannelService {
_room = room;
isConnecting = false;
notifyListeners();
_speakerOn = true;
unawaited(_applySpeaker());
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
unawaited(CallForeground.callStarted('livekit', title: roomDisplayName));
if (voiceChannel && matrixClient != null && matrixRoomId != null) {
await _writePresence(true);
@@ -269,6 +301,7 @@ class LiveKitCallManager extends VoiceChannelService {
isScreenSharing = false;
_currentScreenShareSourceId = null;
_screenShareTrack = null;
CallForeground.screenCaptureStopped('livekit');
}
notifyListeners();
})
@@ -487,6 +520,11 @@ class LiveKitCallManager extends VoiceChannelService {
}
}
// Android 14+: erst Zustimmung + mediaProjection-Dienst, sonst
// stürzt die App beim Mitschnitt ab.
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
if (!await CallForeground.prepareScreenCapture('livekit')) return;
}
await _room?.localParticipant?.setScreenShareEnabled(true);
isScreenSharing = true;
notifyListeners();
@@ -587,6 +625,7 @@ class LiveKitCallManager extends VoiceChannelService {
}
}
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
await CallForeground.screenCaptureStopped('livekit');
isScreenSharing = false;
_currentScreenShareSourceId = null;
_screenShareTrack = null;
@@ -597,6 +636,7 @@ class LiveKitCallManager extends VoiceChannelService {
Future<void> hangUp() async {
// Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration).
_callGeneration++;
unawaited(CallForeground.callEnded('livekit'));
_stopStatsLogging();
_presenceHeartbeat?.cancel();
_presenceHeartbeat = null;
@@ -53,6 +53,11 @@ abstract class VoiceChannelService extends ChangeNotifier {
Future<void> hangUp();
Future<void> toggleMute();
/// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
/// Auf dem PC ohne Bedeutung.
bool get isSpeakerOn => true;
Future<void> toggleSpeaker() async {}
Future<void> toggleDeafen();
Future<void> toggleCamera();
Future<void> toggleScreenShare();
+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,
),
),
],
);
}
}
+16
View File
@@ -270,6 +270,22 @@ class _VoiceSectionState extends ConsumerState<_VoiceSection> {
]),
const SizedBox(height: 16),
_SettingsGroup(title: 'Pegel', pt: pt, children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Builder(builder: (context) {
final voip = ref.watch(callSignalingProvider);
final lk = ref.watch(voiceChannelProvider);
final inCall = voip.currentCall != null || lk.isActive;
// Schlüssel je gewähltem Mikrofon: Gerätewechsel beendet
// einen laufenden Test (sonst misst er das alte Gerät).
return MicLevelTest(
key: ValueKey('mictest:$_selectedMic'),
pt: pt,
enabled: !inCall,
);
}),
),
_Divider(pt: pt),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Column(
+1
View File
@@ -36,6 +36,7 @@ import 'package:pyramid/widgets/update_download_dialog.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:pyramid/widgets/banner_crop_dialog.dart';
import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/mic_level_test.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/pyramid_loader.dart';