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; late final Zone _sdkZone; @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 toggleSpeaker() async { _speakerOn = !_speakerOn; notifyListeners(); await _applySpeaker(); } Future _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; // VOR dem SDK anmelden: Kandidaten der Gegenseite puffern (siehe // _bufferRemoteCandidates). _callEventSub = client.onCallEvents.stream.listen(_bufferRemoteCandidates); // Alles, was das Anruf-SDK anstößt, läuft in einer eigenen Zone: Fehler, // die SDK/WebRTC-Plugin ins Leere werfen (z. B. Lautsprecher-Umschalten // unter Windows, getRemoteDescription wirft statt null), werden // protokolliert statt als „unbehandelt“ durchzuschlagen. _sdkZone = Zone.current.fork( specification: ZoneSpecification( handleUncaughtError: (self, parent, zone, error, stack) { debugPrint('[VOIP] SDK-Fehler (abgefangen): $error'); }, ), ); voip = _sdkZone.run(() => VoIP(client, this)); // Suppress speakerphone call — not supported on Windows/Desktop try { (voip as dynamic).onConfigSpeakerphone = (bool _) async {}; } catch (_) {} _ensureRenderers(); } /// Führt [f] in der SDK-Zone aus. Ergebnis UND Fehler kommen über einen /// Completer von außen zurück – Fehler können Zonengrenzen sonst nicht /// überqueren, und der Aufrufer würde ewig warten. Future _inSdkZone(Future Function() f) { final done = Completer(); _sdkZone.run(() async { try { done.complete(await f()); } catch (e, s) { done.completeError(e, s); } }); return done.future; } @override void dispose() { _callEventSub?.cancel(); _levelTimer?.cancel(); _renderRefreshTimer?.cancel(); super.dispose(); } Future _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 createPeerConnection( Map configuration, [ Map 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.from(configuration); final existing = (merged['iceServers'] as List?)?.cast>() ?? []; merged['iceServers'] = [...existing, ...extra]; configuration = merged; } catch (e) { debugPrint('[VOIP] ICE server merge error: $e'); } return rtc.createPeerConnection(configuration, constraints); } @override Future handleNewCall(CallSession call) async { debugPrint('[VOIP] Incoming call ${call.callId} (type: ${call.type})'); await _ensureRenderers(); currentCall = call; _isCameraMuted = call.type == CallType.kVoice; _speakerOn = true; _isDeafened = false; _isMicMuted = false; _setupCallListeners(call); _updateInternalStates(); notifyListeners(); unawaited(_applyBufferedCandidates(call)); } // ── Netzwerk-Kandidaten der Gegenseite puffern ─────────────────────────── // Lücke im matrix-SDK (6.2): Kommen die Kandidaten des Anrufers an, bevor // die Einladung fertig verarbeitet ist, verwirft das SDK sie endgültig // („we do not have the call“); Kandidaten, die WÄHREND des Annehmens // eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet // wird. Dann kennt man keinen Weg zur Gegenseite – im Heimnetz rettet das // oft noch ein direkter Prüfversuch der anderen Seite, über Mobilfunk/NAT // dauert der Aufbau ewig oder scheitert (Bernd 2026-10-08, im Anruftest // nachgestellt). Deshalb: alle Kandidaten mitschreiben und direkt an die // Verbindung geben, sobald sie die Gegenseite kennt. Doppelte ignoriert // WebRTC. StreamSubscription>? _callEventSub; final _candidateBuffer = >>{}; final _appliedCandidates = {}; void _bufferRemoteCandidates(List events) { for (final e in events) { if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) { continue; } final callId = e.content.tryGet('call_id'); final list = e.content.tryGetList>('candidates'); if (callId == null || list == null) continue; final party = e.content.tryGet('party_id'); (_candidateBuffer[callId] ??= []).addAll( list.map((c) => {...c, '_party': party}), ); // Nur die letzten Anrufe behalten. while (_candidateBuffer.length > 8) { _candidateBuffer.remove(_candidateBuffer.keys.first); } final call = currentCall; if (call != null && call.callId == callId) { unawaited(_applyBufferedCandidates(call)); } } } Future _applyBufferedCandidates(CallSession call) async { final pc = call.pc; final list = _candidateBuffer[call.callId]; if (pc == null || list == null || list.isEmpty) return; try { if (await pc.getRemoteDescription() == null) return; } catch (_) { return; } for (final c in List.of(list)) { final cand = c['candidate']; if (cand is! String || cand.isEmpty) continue; final party = c['_party']; final remoteParty = call.remotePartyId; if (remoteParty != null && party != null && party != remoteParty) continue; if (!_appliedCandidates.add('${call.callId}|$cand')) continue; try { await pc.addCandidate(rtc.RTCIceCandidate( cand, c['sdpMid']?.toString() ?? '', (c['sdpMLineIndex'] as num?)?.round() ?? 0, )); } catch (e) { debugPrint('[VOIP] Kandidat nachreichen: $e'); } } } Timer? _renderRefreshTimer; String? _listenedCallId; void _setupCallListeners(CallSession call) { // Ausgehende Anrufe meldet das SDK zusätzlich über handleNewCall – // Listener nur einmal je Anruf anhängen. if (_listenedCallId == call.callId) return; _listenedCallId = call.callId; 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(); _startLevelPolling(); // Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung. CallForeground.callStarted('voip', title: call.room.getLocalizedDisplayname()); } if (state == CallState.kEnded) { _stopLevelPolling(); 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(); // Gepufferte Kandidaten nachreichen, sobald die Verbindung die // Gegenseite kennt (nach Annehmen bzw. Antwort). final c = currentCall; if (c != null && c.state != CallState.kConnected) { _applyBufferedCandidates(c); } }, ); _updateRenderers(); } void _updateInternalStates() { if (currentCall == null) return; final dynamic c = currentCall; try { _isMicMuted = c.isMicrophoneMuted == true; _isCameraMuted = c.isLocalVideoMuted == true; } catch (_) {} notifyListeners(); } Future _updateRenderers() async { final call = currentCall; if (call == null || _isUpdating) return; _isUpdating = true; try { await _ensureRenderers(); // Typisierte SDK-Zugriffe. Früher lief das über ein dynamisches // `getFeeds()` – das gibt es im SDK gar nicht; der Fehler wurde // geschluckt, und Bildschirmübertragungen wurden weder beim Empfänger // angezeigt noch beim Sender als aktiv erkannt (Bernd 2026-10-08). rtc.MediaStream? video(WrappedMediaStream? w, {bool respectMute = false}) { final s = w?.stream; // stopped: Gegenseite hat die Übertragung beendet (das SDK lässt den // Strom stehen und markiert ihn nur). if (w == null || w.stopped || s == null || s.getVideoTracks().isEmpty) { return null; } if (respectMute && w.videoMuted) return null; return s; } final remoteScreen = video(call.remoteScreenSharingStream); final localScreen = video(call.localScreenSharingStream); final remoteUser = call.remoteUserMediaStream?.stream; var changed = false; final remoteSharing = remoteScreen != null; final localSharing = call.localScreenSharingStream != null; if (remoteSharing != _isRemoteScreensharing || localSharing != _isScreensharing) { changed = true; } _isRemoteScreensharing = remoteSharing; _isScreensharing = localSharing; if (_isDeafened) _applyDeafen(call); // Ausgabelautstärke (Settings-Slider) auf den Ton der Gegenseite – // nur einmal pro Stream, _updateRenderers läuft auch per Timer. if (remoteUser != null && remoteUser.id != _volumeAppliedStreamId) { _volumeAppliedStreamId = remoteUser.id; _remoteAudioStream = remoteUser; applyOutputVolume(); } // Bildschirm hat Vorrang vor der Kamera. final remoteVideo = remoteScreen ?? video(call.remoteUserMediaStream, respectMute: true); final localVideo = localScreen ?? video(call.localUserMediaStream, respectMute: true); if (remoteRenderer.srcObject?.id != remoteVideo?.id) { debugPrint('[VOIP] Remote-Video: ${remoteVideo?.id} (Bildschirm: $remoteSharing)'); remoteRenderer.srcObject = remoteVideo; changed = true; } if (localRenderer.srcObject?.id != localVideo?.id) { debugPrint('[VOIP] Lokales Video: ${localVideo?.id} (Bildschirm: $localSharing)'); localRenderer.srcObject = localVideo; changed = true; } if (changed) notifyListeners(); } catch (e) { debugPrint('[VOIP] Error in _updateRenderers: $e'); } finally { _isUpdating = false; } } // ── Wer spricht gerade? (Pegel aus den WebRTC-Statistiken) ─────────────── // Zeigt den grünen Sprech-Ring wie bei Discord – und macht sichtbar, wo // Ton verloren geht: leuchtet „Du“ beim Sprechen nicht, kommt vom eigenen // Mikrofon nichts an; leuchtet die Gegenseite nicht, kommt nichts an. Timer? _levelTimer; bool _localSpeaking = false; bool _remoteSpeaking = false; @override bool get isLocalSpeaking => _localSpeaking; @override bool get isRemoteSpeaking => _remoteSpeaking; void _startLevelPolling() { _levelTimer?.cancel(); _levelTimer = Timer.periodic( const Duration(milliseconds: 300), (_) => _pollLevels(), ); } void _stopLevelPolling() { _levelTimer?.cancel(); _levelTimer = null; _localSpeaking = false; _remoteSpeaking = false; } Future _pollLevels() async { final pc = currentCall?.pc; if (pc == null) return; try { double local = 0; double remote = 0; for (final r in await pc.getStats()) { if (r.values['kind'] != 'audio') continue; final level = (r.values['audioLevel'] as num?)?.toDouble() ?? 0; if (r.type == 'media-source' && level > local) local = level; if (r.type == 'inbound-rtp' && level > remote) remote = level; } // Hysterese: an ab 0,03, aus unter 0,01 – kein Flackern. bool speaking(bool was, double level) => was ? level > 0.01 : level > 0.03; final ls = speaking(_localSpeaking, _isMicMuted ? 0 : local); final rs = speaking(_remoteSpeaking, remote); if (ls != _localSpeaking || rs != _remoteSpeaking) { _localSpeaking = ls; _remoteSpeaking = rs; notifyListeners(); } } catch (_) {} } @override Future handleCallEnded(CallSession session) async { if (currentCall?.callId == session.callId) { _renderRefreshTimer?.cancel(); _renderRefreshTimer = null; _stopLevelPolling(); 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 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 handleMissedCall(CallSession session) async { if (currentCall?.callId == session.callId) { currentCall = null; notifyListeners(); } CallForeground.callEnded('voip'); } @override Future handleNewGroupCall(GroupCallSession groupCall) async {} @override Future handleGroupCallEnded(GroupCallSession groupCall) async {} @override bool get canHandleNewCall => currentCall == null; @override Future playRingtone() async {} @override Future stopRingtone() async {} @override EncryptionKeyProvider? get keyProvider => null; @override Future registerListeners(CallSession session) async {} @override Future safeAction(Future Function() action) async { try { await _inSdkZone(action); await Future.delayed(const Duration(milliseconds: 1000)); } catch (e) { debugPrint('[VOIP] Action error: $e'); } _updateInternalStates(); _updateRenderers(); } @override Future toggleMic() => _setMicMuted(!_isMicMuted); Future _setMicMuted(bool muted) async { final c = currentCall; if (c == null) return; _isMicMuted = muted; notifyListeners(); try { await _inSdkZone(() => c.setMicrophoneMuted(muted)); } catch (e) { // Das SDK schaltet die Spur VOR dem Senden der Status-Info um. Schlägt // nur das Senden fehl, ist das Mikro trotzdem (ent)stummt – früher // wurde hier blind zurückgeschaltet, und der Knopf zeigte danach das // Gegenteil des echten Zustands (Bernd 2026-10-08). debugPrint('[VOIP] Stummschalten: $e'); } _isMicMuted = c.isMicrophoneMuted; notifyListeners(); } @override Future toggleCamera() async { if (currentCall == null) return; _isCameraMuted = !_isCameraMuted; notifyListeners(); try { final c = currentCall!; await _inSdkZone(() => c.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 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 toggleDeafen() async { // Wie Discord: taub = Gegenseite nicht mehr hören UND selbst stumm; // beim Aufheben den Mikro-Zustand von vorher wiederherstellen. (Früher // setzte der Knopf nur ein Häkchen ohne Wirkung.) _isDeafened = !_isDeafened; notifyListeners(); final c = currentCall; if (c == null) return; _applyDeafen(c); if (_isDeafened) { _micMutedBeforeDeafen = _isMicMuted; if (!_isMicMuted) await _setMicMuted(true); } else if (!_micMutedBeforeDeafen && _isMicMuted) { await _setMicMuted(false); } } bool _micMutedBeforeDeafen = false; /// Ton der Gegenseite an/aus (deaktivierte Empfangsspur = stumm). void _applyDeafen(CallSession call) { for (final w in call.getRemoteStreams) { for (final t in w.stream?.getAudioTracks() ?? const []) { if (t.enabled == _isDeafened) t.enabled = !_isDeafened; } } } @override Future hangup() async { if (currentCall == null) return; await safeAction(() async { await currentCall!.hangup(reason: CallErrorCode.userHangup); currentCall = null; }); await CallForeground.callEnded('voip'); } @override Future 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 _inSdkZone( () => voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice)); currentCall = call; _setupCallListeners(call); if (video) { await Future.delayed(const Duration(milliseconds: 800)); await _inSdkZone(() => call.setLocalVideoMuted(false)); await _updateRenderers(); } _updateInternalStates(); notifyListeners(); } @override Future 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 getUserMedia(Map constraints) async { final Map improvedConstraints = Map.from(constraints); final String? sourceId = _manager._selectedSourceId; if (sourceId != null) { if (improvedConstraints['video'] is! Map) { improvedConstraints['video'] = {}; } final v = Map.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.from(improvedConstraints['audio']) : {}; 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.from(improvedConstraints['video']) : {}; 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.from(improvedConstraints['video']); v['width'] = {'ideal': width}; v['height'] = {'ideal': height}; improvedConstraints['video'] = v; } } return _delegate.getUserMedia(improvedConstraints); } @override Future getDisplayMedia(Map 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> 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> getSources() => _delegate.getSources(); @override Future selectAudioOutput([AudioOutputOptions? options]) => _delegate.selectAudioOutput(options); }