Files
pyramid/lib/features/call_signaling/voip_manager.dart
T
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

613 lines
19 KiB
Dart
Raw 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 'package:flutter/foundation.dart';
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';
import 'package:pyramid/widgets/screen_share_picker.dart';
/// Implementierung von [CallSignalingService] (Modul `call_signaling`).
/// Wird ausschließlich vom Kompositions-Punkt (`matrix_client.dart`)
/// konstruiert; alle anderen gehen über `callSignalingProvider`.
class PyramidVoipManager extends CallSignalingService
implements WebRTCDelegate {
static PyramidVoipManager? _instance;
static PyramidVoipManager get instance => _instance!;
final Client client;
late final VoIP voip;
@override
CallSession? currentCall;
@override
final rtc.RTCVideoRenderer localRenderer = rtc.RTCVideoRenderer();
@override
final rtc.RTCVideoRenderer remoteRenderer = rtc.RTCVideoRenderer();
bool _renderersInitialized = false;
bool _isMicMuted = false;
bool _isCameraMuted = true;
bool _isScreensharing = false;
bool _isRemoteScreensharing = false;
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
bool get isMicMuted => _isMicMuted;
@override
bool get isCameraMuted => _isCameraMuted;
@override
bool get isScreensharing => _isScreensharing;
@override
bool get isRemoteScreensharing => _isRemoteScreensharing;
@override
bool get isDeafened => _isDeafened;
@override
String get currentQualityKey => _currentQualityKey;
PyramidVoipManager(this.client) {
_instance = this;
voip = VoIP(client, this);
// Suppress speakerphone call — not supported on Windows/Desktop
try {
(voip as dynamic).onConfigSpeakerphone = (bool _) async {};
} catch (_) {}
_ensureRenderers();
}
Future<void> _ensureRenderers() async {
if (_renderersInitialized) return;
try {
await localRenderer.initialize();
await remoteRenderer.initialize();
_renderersInitialized = true;
} catch (e) {
debugPrint('[VOIP] Renderer init error: $e');
}
}
@override
MediaDevices get mediaDevices => MediaDevicesWrapper(rtc.navigator.mediaDevices, this);
@override
bool get isWeb => kIsWeb;
@override
Future<RTCPeerConnection> createPeerConnection(
Map<String, dynamic> configuration, [
Map<String, dynamic> constraints = const {},
]) async {
// Zusätzliche ICE-Server (STUN/TURN aus ice.json) zu den vom Homeserver
// gelieferten mergen — ohne STUN scheitern Calls außerhalb des LANs,
// weil der Server hinter CGNAT hängt.
try {
final extra = await IceServers.get();
final merged = Map<String, dynamic>.from(configuration);
final existing =
(merged['iceServers'] as List?)?.cast<Map<String, dynamic>>() ?? [];
merged['iceServers'] = [...existing, ...extra];
configuration = merged;
} catch (e) {
debugPrint('[VOIP] ICE server merge error: $e');
}
return rtc.createPeerConnection(configuration, constraints);
}
@override
Future<void> handleNewCall(CallSession call) async {
debugPrint('[VOIP] Incoming call ${call.callId} (type: ${call.type})');
await _ensureRenderers();
currentCall = call;
_isCameraMuted = call.type == CallType.kVoice;
_speakerOn = true;
_setupCallListeners(call);
_updateInternalStates();
notifyListeners();
}
Timer? _renderRefreshTimer;
void _setupCallListeners(CallSession call) {
call.onCallStateChanged.stream.listen((state) {
debugPrint('[VOIP] State: $state');
_updateInternalStates();
if (state == CallState.kConnected) {
if (!_isCameraMuted) {
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(
const Duration(milliseconds: 1000), // Faster polling to catch feeds
(_) => _updateRenderers(),
);
_updateRenderers();
}
void _updateInternalStates() {
if (currentCall == null) return;
final dynamic c = currentCall;
try {
_isMicMuted = c.isMicrophoneMuted == true;
_isCameraMuted = c.isLocalVideoMuted == true;
} catch (_) {}
notifyListeners();
}
Future<void> _updateRenderers() async {
if (currentCall == null || _isUpdating) return;
_isUpdating = true;
try {
await _ensureRenderers();
final dynamic c = currentCall;
rtc.MediaStream? remote;
rtc.MediaStream? local;
rtc.MediaStream? extract(dynamic obj) {
if (obj == null) return null;
if (obj is rtc.MediaStream) return obj;
try { return obj.stream as rtc.MediaStream?; } catch (_) {}
return null;
}
final List<dynamic> feeds = [];
try {
final dynamic sdkFeeds = c.getFeeds();
if (sdkFeeds is Iterable) feeds.addAll(sdkFeeds.toList());
} catch (_) {}
bool remoteScreenFound = false;
bool localScreenFound = false;
for (final dynamic f in feeds) {
final s = extract(f);
if (s == null) continue;
bool isLocal = false;
try { isLocal = f.isLocal == true || f.userId == client.userID; } catch (_) {}
String purpose = 'm.usermedia';
try { purpose = f.purpose ?? 'm.usermedia'; } catch (_) {}
if (isLocal) {
if (local == null || purpose == 'm.screenshare') {
local = s;
if (purpose == 'm.screenshare') localScreenFound = true;
}
} else {
if (remote == null || purpose == 'm.screenshare') {
remote = s;
if (purpose == 'm.screenshare') remoteScreenFound = true;
}
}
}
_isScreensharing = localScreenFound;
_isRemoteScreensharing = remoteScreenFound;
if (remote == null) {
try { remote = extract(c.remoteUserMediaStream); } catch (_) {}
}
if (remote == null) {
try { remote = extract(c.remoteStream); } catch (_) {}
}
if (local == null) {
try { local = extract(c.localUserMediaStream); } catch (_) {}
}
// Ausgabelautstärke (Settings-Slider) auf den Remote-Stream anwenden —
// nur einmal pro Stream, _updateRenderers läuft auch per Timer.
if (remote != null && remote.id != _volumeAppliedStreamId) {
_volumeAppliedStreamId = remote.id;
_remoteAudioStream = remote;
applyOutputVolume();
}
// Final check for tracks before assigning
bool changed = false;
if (remote != null && remote.getVideoTracks().isNotEmpty) {
if (remoteRenderer.srcObject?.id != remote.id) {
debugPrint('[VOIP] Assigning Remote Renderer: ${remote.id} (${remote.getVideoTracks().length} video tracks)');
remoteRenderer.srcObject = remote;
changed = true;
}
} else if (remoteRenderer.srcObject != null) {
remoteRenderer.srcObject = null;
changed = true;
}
if (local != null && local.getVideoTracks().isNotEmpty) {
if (localRenderer.srcObject?.id != local.id) {
debugPrint('[VOIP] Assigning Local Renderer: ${local.id} (${local.getVideoTracks().length} video tracks)');
localRenderer.srcObject = local;
changed = true;
}
} else if (localRenderer.srcObject != null) {
localRenderer.srcObject = null;
changed = true;
}
if (changed) notifyListeners();
} catch (e) {
debugPrint('[VOIP] Error in _updateRenderers: $e');
} finally {
_isUpdating = false;
}
}
@override
Future<void> handleCallEnded(CallSession session) async {
if (currentCall?.callId == session.callId) {
_renderRefreshTimer?.cancel();
_renderRefreshTimer = null;
localRenderer.srcObject = null;
remoteRenderer.srcObject = null;
_remoteAudioStream = null;
_volumeAppliedStreamId = null;
currentCall = null;
_isScreensharing = false;
_isRemoteScreensharing = false;
notifyListeners();
}
CallForeground.callEnded('voip');
}
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
rtc.MediaStream? _remoteAudioStream;
String? _volumeAppliedStreamId;
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
/// Remote-Audio-Stream an. Ohne [value] wird der Pref-Wert gelesen.
@override
Future<void> applyOutputVolume([double? value]) async {
var v = value;
if (v == null) {
try {
v = await VoicePrefs.loadOutputVolume();
} catch (_) {
v = 1.0;
}
}
final stream = _remoteAudioStream;
if (stream == null) return;
for (final track in stream.getAudioTracks()) {
try {
await rtc.Helper.setVolume(v.clamp(0.0, 2.0), track);
} catch (_) {}
}
}
@override
Future<void> handleMissedCall(CallSession session) async {
if (currentCall?.callId == session.callId) {
currentCall = null;
notifyListeners();
}
CallForeground.callEnded('voip');
}
@override
Future<void> handleNewGroupCall(GroupCallSession groupCall) async {}
@override
Future<void> handleGroupCallEnded(GroupCallSession groupCall) async {}
@override
bool get canHandleNewCall => currentCall == null;
@override
Future<void> playRingtone() async {}
@override
Future<void> stopRingtone() async {}
@override
EncryptionKeyProvider? get keyProvider => null;
@override
Future<void> registerListeners(CallSession session) async {}
@override
Future<void> safeAction(Future<dynamic> Function() action) async {
try {
await action();
await Future.delayed(const Duration(milliseconds: 1000));
} catch (e) {
debugPrint('[VOIP] Action error: $e');
}
_updateInternalStates();
_updateRenderers();
}
@override
Future<void> toggleMic() async {
if (currentCall == null) return;
_isMicMuted = !_isMicMuted;
notifyListeners();
try {
await currentCall!.setMicrophoneMuted(_isMicMuted);
} catch (e) {
_isMicMuted = !_isMicMuted;
notifyListeners();
}
}
@override
Future<void> toggleCamera() async {
if (currentCall == null) return;
_isCameraMuted = !_isCameraMuted;
notifyListeners();
try {
await currentCall!.setLocalVideoMuted(_isCameraMuted);
for (var i = 0; i < 5; i++) {
await Future.delayed(Duration(milliseconds: 300 + (i * 200)));
await _updateRenderers();
}
} catch (e) {
debugPrint('[VOIP] toggleCamera error: $e');
_isCameraMuted = !_isCameraMuted;
notifyListeners();
}
_updateInternalStates();
await _updateRenderers();
}
@override
Future<void> toggleScreenSharing([BuildContext? context]) async {
if (currentCall == null) return;
final call = currentCall!;
if (_isScreensharing) {
await safeAction(() => call.setScreensharingEnabled(false));
await CallForeground.screenCaptureStopped('voip');
return;
}
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && context != null) {
final selectedSource = await ScreenSharePicker.show(context);
if (selectedSource == null) return;
_selectedSourceId = selectedSource.id;
} else {
_selectedSourceId = null;
}
await safeAction(() async {
await call.setScreensharingEnabled(true);
});
}
@override
Future<void> toggleDeafen() async {
_isDeafened = !_isDeafened;
notifyListeners();
}
@override
Future<void> hangup() async {
if (currentCall == null) return;
await safeAction(() async {
await currentCall!.hangup(reason: CallErrorCode.userHangup);
currentCall = null;
});
await CallForeground.callEnded('voip');
}
@override
Future<void> startCall(String roomId, {bool video = false}) async {
final room = client.getRoomById(roomId);
if (room == null) return;
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;
_setupCallListeners(call);
if (video) {
await Future.delayed(const Duration(milliseconds: 800));
await call.setLocalVideoMuted(false);
await _updateRenderers();
}
_updateInternalStates();
notifyListeners();
}
@override
Future<void> changeQuality(String key) async {
_currentQualityKey = key;
if (currentCall == null) {
notifyListeners();
return;
}
if (!_isCameraMuted) {
await toggleCamera();
await Future.delayed(const Duration(milliseconds: 300));
await toggleCamera();
}
if (_isScreensharing) {
final String? oldSource = _selectedSourceId;
await safeAction(() => currentCall!.setScreensharingEnabled(false));
await Future.delayed(const Duration(milliseconds: 300));
_selectedSourceId = oldSource;
await safeAction(() => currentCall!.setScreensharingEnabled(true));
}
notifyListeners();
}
}
class MediaDevicesWrapper extends MediaDevices {
final MediaDevices _delegate;
final PyramidVoipManager _manager;
MediaDevicesWrapper(this._delegate, this._manager);
@override
Future<rtc.MediaStream> getUserMedia(Map<String, dynamic> constraints) async {
final Map<String, dynamic> improvedConstraints = Map.from(constraints);
final String? sourceId = _manager._selectedSourceId;
if (sourceId != null) {
if (improvedConstraints['video'] is! Map) {
improvedConstraints['video'] = {};
}
final v = Map<String, dynamic>.from(improvedConstraints['video']);
v['mandatory'] ??= {};
v['mandatory']['chromeMediaSource'] = 'desktop';
v['mandatory']['chromeMediaSourceId'] = sourceId;
improvedConstraints['video'] = v;
}
// In den Einstellungen gewählte Geräte (voice_*_device Prefs) anwenden —
// nur wenn kein Screenshare-Source gesetzt ist (sonst nicht anfassen).
try {
final devicePrefs = await VoicePrefs.loadDevices();
final mic = devicePrefs.mic;
final cam = devicePrefs.camera;
final speaker = devicePrefs.speaker;
if (mic != null &&
improvedConstraints['audio'] != null &&
improvedConstraints['audio'] != false) {
final a = improvedConstraints['audio'] is Map
? Map<String, dynamic>.from(improvedConstraints['audio'])
: <String, dynamic>{};
a['deviceId'] = mic;
a['optional'] = [
...?(a['optional'] as List?),
{'sourceId': mic},
];
improvedConstraints['audio'] = a;
}
if (cam != null &&
sourceId == null &&
improvedConstraints['video'] != null &&
improvedConstraints['video'] != false) {
final v = improvedConstraints['video'] is Map
? Map<String, dynamic>.from(improvedConstraints['video'])
: <String, dynamic>{};
v['deviceId'] = cam;
improvedConstraints['video'] = v;
}
if (speaker != null) {
rtc.Helper.selectAudioOutput(speaker).catchError((_) {});
}
} catch (_) {}
if (improvedConstraints['video'] != null && improvedConstraints['video'] != false) {
final String q = _manager.currentQualityKey;
int width = 1280;
int height = 720;
if (q == 'sd') { width = 640; height = 360; }
else if (q == 'fhd') { width = 1920; height = 1080; }
else if (q == '4k') { width = 3840; height = 2160; }
if (improvedConstraints['video'] is bool) {
improvedConstraints['video'] = {
'width': {'ideal': width},
'height': {'ideal': height},
'frameRate': {'ideal': 30},
};
} else if (improvedConstraints['video'] is Map) {
final v = Map<String, dynamic>.from(improvedConstraints['video']);
v['width'] = {'ideal': width};
v['height'] = {'ideal': height};
improvedConstraints['video'] = v;
}
}
return _delegate.getUserMedia(improvedConstraints);
}
@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 = {
'video': {
'mandatory': {
'chromeMediaSource': 'desktop',
'chromeMediaSourceId': sourceId,
},
'optional': [],
},
'audio': false,
};
return _delegate.getDisplayMedia(desktopConstraints);
}
return _delegate.getDisplayMedia(constraints);
}
@override
Future<List<MediaDeviceInfo>> enumerateDevices() => _delegate.enumerateDevices();
@override
MediaTrackSupportedConstraints getSupportedConstraints() => _delegate.getSupportedConstraints();
@override
set ondevicechange(Function(dynamic event)? callback) => _delegate.ondevicechange = callback;
@override
Function(dynamic event)? get ondevicechange => _delegate.ondevicechange;
// Erzwungener Override der abstrakten MediaDevices-Klasse, die getSources()
// selbst als deprecated markiert (Ersatz: enumerateDevices()) aber weiterhin verlangt.
@override
// ignore: deprecated_member_use
Future<List<dynamic>> getSources() => _delegate.getSources();
@override
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) => _delegate.selectAudioOutput(options);
}