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:shared_preferences/shared_preferences.dart'; import 'package:webrtc_interface/webrtc_interface.dart'; import 'package:pyramid/core/ice_servers.dart'; import 'package:pyramid/widgets/screen_share_picker.dart'; class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate { static PyramidVoipManager? _instance; static PyramidVoipManager get instance => _instance!; final Client client; late final VoIP voip; CallSession? currentCall; final rtc.RTCVideoRenderer localRenderer = rtc.RTCVideoRenderer(); 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; String _currentQualityKey = 'hd'; bool get isMicMuted => _isMicMuted; bool get isCameraMuted => _isCameraMuted; bool get isScreensharing => _isScreensharing; bool get isRemoteScreensharing => _isRemoteScreensharing; bool get isDeafened => _isDeafened; 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 _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; _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); } } _updateRenderers(); }); _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 _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 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 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(); } } // ── 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. Future applyOutputVolume([double? value]) async { var v = value; if (v == null) { try { final prefs = await SharedPreferences.getInstance(); v = prefs.getDouble('voice_output_volume') ?? 1.0; } 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(); } } @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 {} Future safeAction(Future Function() action) async { try { await action(); await Future.delayed(const Duration(milliseconds: 1000)); } catch (e) { debugPrint('[VOIP] Action error: $e'); } _updateInternalStates(); _updateRenderers(); } Future toggleMic() async { if (currentCall == null) return; _isMicMuted = !_isMicMuted; notifyListeners(); try { await currentCall!.setMicrophoneMuted(_isMicMuted); } catch (e) { _isMicMuted = !_isMicMuted; notifyListeners(); } } Future 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(); } Future toggleScreenSharing([BuildContext? context]) async { if (currentCall == null) return; final call = currentCall!; if (_isScreensharing) { await safeAction(() => call.setScreensharingEnabled(false)); 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); }); } Future toggleDeafen() async { _isDeafened = !_isDeafened; notifyListeners(); } Future hangup() async { if (currentCall == null) return; await safeAction(() async { await currentCall!.hangup(reason: CallErrorCode.userHangup); currentCall = null; }); } Future startCall(String roomId, {bool video = false}) async { final room = client.getRoomById(roomId); if (room == null) return; await _ensureRenderers(); _isCameraMuted = !video; 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(); } 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 prefs = await SharedPreferences.getInstance(); final mic = prefs.getString('voice_mic_device'); final cam = prefs.getString('voice_camera_device'); final speaker = prefs.getString('voice_speaker_device'); 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 { 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); }