import 'dart:async'; 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/ice_servers.dart'; import 'package:pyramid/core/livekit_token.dart'; import 'package:shared_preferences/shared_preferences.dart'; const _kVoicePresenceType = 'io.pyramid.voice.presence'; class LiveKitCallManager with ChangeNotifier { static final LiveKitCallManager instance = LiveKitCallManager._(); LiveKitCallManager._(); // ── Publish quality presets (Streamer → Server) ────────────────────────── static final Map qualityPresets = { 'sd': VideoParametersPresets.h360_169, 'hd': VideoParametersPresets.h720_169, 'fhd': VideoParametersPresets.h1080_169, '4k': VideoParametersPresets.h2160_169, }; static const Map _screenShareEncodings = { 'sd': VideoEncoding(maxBitrate: 500_000, maxFramerate: 10), 'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15), 'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20), '4k': VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30), }; Room? _room; EventsListener? _roomEventListener; String _displayName = ''; bool isConnecting = false; String? error; bool isMuted = false; bool isCameraOff = false; bool isScreenSharing = false; String _localQualityKey = 'hd'; String _subscribeQualityKey = 'auto'; // 'auto' | 'low' | 'medium' | 'high' String? _currentScreenShareSourceId; LocalVideoTrack? _screenShareTrack; bool isDeafened = false; bool _mutedBeforeDeafen = false; bool isVoiceChannel = false; String? _matrixRoomId; Client? _matrixClient; Timer? _presenceHeartbeat; Timer? _statsTimer; bool get isActive => _room != null || isConnecting; Room? get room => _room; String? get currentRoomId => _matrixRoomId; String get displayName => _displayName; bool get noiseSuppression => false; String get currentQualityKey => _localQualityKey; String get subscribeQualityKey => _subscribeQualityKey; VideoParameters get _currentQuality => qualityPresets[_localQualityKey] ?? VideoParametersPresets.h720_169; void _onRoomChanged() => notifyListeners(); void _cleanUpAfterDisconnect() { _stopStatsLogging(); _presenceHeartbeat?.cancel(); _presenceHeartbeat = null; if (_matrixClient != null && _matrixRoomId != null) { _writePresence(false).catchError((_) {}); } _roomEventListener?.dispose(); _roomEventListener = null; _room?.removeListener(_onRoomChanged); _room = null; _displayName = ''; isConnecting = false; error = null; isMuted = false; isCameraOff = false; isScreenSharing = false; isDeafened = false; isVoiceChannel = false; _mutedBeforeDeafen = false; _screenShareTrack = null; _currentScreenShareSourceId = null; _matrixRoomId = null; _matrixClient = null; notifyListeners(); } Future _writePresence(bool active) async { final client = _matrixClient; final roomId = _matrixRoomId; final userId = client?.userID; if (client == null || roomId == null || userId == null) return; try { Map content; if (active) { // Resolve display name + avatar from any joined room's member state User? member; for (final r in client.rooms) { final participants = r.getParticipants(); try { member = participants.firstWhere((u) => u.id == userId); if (member.displayName != null || member.avatarUrl != null) break; } catch (_) {} } content = { 'active': true, 'joined_at': DateTime.now().millisecondsSinceEpoch, 'display_name': member?.displayName ?? userId, if (member?.avatarUrl != null) 'avatar_url': member!.avatarUrl.toString(), }; } else { content = {'active': false}; } await client.setRoomStateWithKey(roomId, _kVoicePresenceType, userId, content); } catch (e) { debugPrint('[VoicePresence] $e'); } } Future startCall({ required String roomName, required String roomDisplayName, required String identity, bool audioOnly = false, bool voiceChannel = false, Client? matrixClient, String? matrixRoomId, }) async { if (isActive) return; final url = 'wss://livekit.steggi-matrix.work'; isConnecting = true; isVoiceChannel = voiceChannel; error = null; _displayName = roomDisplayName; isCameraOff = audioOnly; isMuted = false; isScreenSharing = false; isDeafened = false; _mutedBeforeDeafen = false; _localQualityKey = 'hd'; _subscribeQualityKey = 'auto'; _matrixClient = matrixClient; _matrixRoomId = matrixRoomId; notifyListeners(); try { final token = LiveKitTokenGenerator.generate( roomName: roomName, identity: identity, displayName: identity, ttlSeconds: 21600, ); final room = Room( roomOptions: const RoomOptions( adaptiveStream: false, // Matches FluffyChat fix dynacast: true, ), ); room.addListener(_onRoomChanged); // Eigene ICE-Server (STUN/TURN aus ice.json) mitgeben. Client-seitige // iceServers ERSETZEN die vom LiveKit-Server gelieferten — die zeigten // bisher auf einen hinter CGNAT unerreichbaren coturn. RTCConfiguration rtcConfig = const RTCConfiguration(); try { final ice = await IceServers.get(); rtcConfig = RTCConfiguration( iceServers: ice .map((m) => RTCIceServer( urls: m['urls'] is List ? List.from(m['urls'] as List) : [m['urls'] as String], username: m['username'] as String?, credential: m['credential'] as String?, )) .toList(), ); } catch (e) { debugPrint('[LiveKit] ICE server fetch error: $e'); } await room.connect( url, token, connectOptions: ConnectOptions(rtcConfiguration: rtcConfig), ); _room = room; isConnecting = false; notifyListeners(); if (voiceChannel && matrixClient != null && matrixRoomId != null) { await _writePresence(true); _presenceHeartbeat = Timer.periodic(const Duration(seconds: 30), (_) { _writePresence(true).catchError((_) {}); }); } _roomEventListener = room.createListener() ..on((_) => notifyListeners()) ..on((event) { if (event.publication.source == TrackSource.screenShareVideo) { isScreenSharing = false; _currentScreenShareSourceId = null; _screenShareTrack = null; } notifyListeners(); }) ..on((event) { final pub = event.publication; if (_subscribeQualityKey != 'auto' && pub.kind == TrackType.VIDEO) { pub.setVideoQuality(_videoQualityFromKey(_subscribeQualityKey)); } // Ausgabelautstärke aus den Einstellungen auf neue Audio-Tracks. if (pub.kind == TrackType.AUDIO) { try { rtc.Helper.setVolume( _outputVolume.clamp(0.0, 2.0), event.track.mediaStreamTrack); } catch (_) {} } notifyListeners(); }) ..on((_) => notifyListeners()) ..on((_) => notifyListeners()) ..on((_) => notifyListeners()) ..on((_) => notifyListeners()) ..on((_) => notifyListeners()) ..on((_) { _cleanUpAfterDisconnect(); }); // In den Einstellungen gewählte Geräte anwenden (voice_*_device Prefs). // Stale-IDs (abgestecktes Headset etc.) werden gegen die aktuell // vorhandenen Geräte validiert und sonst ignoriert. String? prefMic, prefSpeaker, prefCam; var deviceIds = {}; try { final prefs = await SharedPreferences.getInstance(); _outputVolume = prefs.getDouble('voice_output_volume') ?? 1.0; prefMic = prefs.getString('voice_mic_device'); prefSpeaker = prefs.getString('voice_speaker_device'); prefCam = prefs.getString('voice_camera_device'); final devices = await rtc.navigator.mediaDevices.enumerateDevices(); deviceIds = devices.map((d) => d.deviceId).toSet(); } catch (e) { debugPrint('[LiveKit] Device prefs/enumerate error: $e'); } if (prefMic != null && !deviceIds.contains(prefMic)) prefMic = null; if (prefSpeaker != null && !deviceIds.contains(prefSpeaker)) prefSpeaker = null; if (prefCam != null && !deviceIds.contains(prefCam)) prefCam = null; await room.localParticipant?.setMicrophoneEnabled( true, audioCaptureOptions: prefMic != null ? AudioCaptureOptions(deviceId: prefMic) : null, ); if (prefSpeaker != null) { try { await rtc.Helper.selectAudioOutput(prefSpeaker); } catch (e) { debugPrint('[LiveKit] selectAudioOutput error: $e'); } } if (!audioOnly) { try { await room.localParticipant?.setCameraEnabled( true, cameraCaptureOptions: CameraCaptureOptions( deviceId: prefCam, params: _currentQuality, ), ); } catch (e) { debugPrint('[LiveKit] Failed to enable camera: $e'); } } _startStatsLogging(); } catch (e) { debugPrint('[VOIP] Start call error: $e'); isConnecting = false; error = e.toString(); _cleanUpAfterDisconnect(); } } VideoQuality _videoQualityFromKey(String key) { switch (key) { case 'low': return VideoQuality.LOW; case 'medium': return VideoQuality.MEDIUM; case 'high': return VideoQuality.HIGH; default: return VideoQuality.HIGH; // auto defaults to high } } // ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ───────────── double _outputVolume = 1.0; /// Live-Anwendung des Lautstärke-Sliders auf alle Remote-Audio-Tracks. Future setOutputVolume(double v) async { _outputVolume = v; final room = _room; if (room == null) return; for (final p in room.remoteParticipants.values) { for (final pub in p.audioTrackPublications) { final t = pub.track; if (t != null) { try { await rtc.Helper.setVolume(v.clamp(0.0, 2.0), t.mediaStreamTrack); } catch (_) {} } } } } Future setSubscribeQuality(String key) async { debugPrint('[LiveKit] Viewer changing subscribe quality to: $key'); _subscribeQualityKey = key; if (_room == null) { notifyListeners(); return; } final quality = _videoQualityFromKey(key); for (final p in _room!.remoteParticipants.values) { for (final pub in p.videoTrackPublications) { if (pub.kind == TrackType.VIDEO) { debugPrint('[LiveKit] Setting subscription quality for ${p.identity} to $quality'); pub.setVideoQuality(quality); } } } notifyListeners(); } Future toggleMute() async { isMuted = !isMuted; notifyListeners(); await _room?.localParticipant?.setMicrophoneEnabled(!isMuted); } Future toggleDeafen() async { if (!isDeafened) { _mutedBeforeDeafen = isMuted; isDeafened = true; if (!isMuted) { await _room?.localParticipant?.setMicrophoneEnabled(false); isMuted = true; } } else { isDeafened = false; if (!_mutedBeforeDeafen) { await _room?.localParticipant?.setMicrophoneEnabled(true); isMuted = false; } } _applyDeafen(); notifyListeners(); } void _applyDeafen() { if (_room == null) return; for (final p in _room!.remoteParticipants.values) { for (final trackPub in p.audioTrackPublications) { final track = trackPub.track; if (track != null) { track.mediaStreamTrack.enabled = !isDeafened; } } } } Future toggleCamera() async { if (isCameraOff) { try { await _room?.localParticipant?.setCameraEnabled( true, cameraCaptureOptions: CameraCaptureOptions(params: _currentQuality), ); isCameraOff = false; } catch (e) { debugPrint('[LiveKit] toggleCamera ON error: $e'); } } else { try { await _room?.localParticipant?.setCameraEnabled(false); } catch (e) { debugPrint('[LiveKit] toggleCamera OFF error: $e'); } isCameraOff = true; } notifyListeners(); } Future toggleScreenShare() async { if (isScreenSharing) { await stopScreenShare(); } else { try { if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows) { final sources = await rtc.desktopCapturer.getSources(types: [rtc.SourceType.Screen, rtc.SourceType.Window]); if (sources.isNotEmpty) { await startScreenShare(sources.first.id); return; } } await _room?.localParticipant?.setScreenShareEnabled(true); isScreenSharing = true; notifyListeners(); } catch (e) { debugPrint('[ScreenShare] Toggle error: $e'); } } } VideoEncoding _screenShareEncoding() { return _screenShareEncodings[_localQualityKey] ?? const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15); } Future startScreenShare(String sourceId) async { if (isScreenSharing) return; try { final encoding = _screenShareEncoding(); final track = await LocalVideoTrack.createScreenShareTrack( ScreenShareCaptureOptions( sourceId: sourceId, maxFrameRate: encoding.maxFramerate.toDouble(), ), ); if (_room == null || _room!.localParticipant == null) return; await _room!.localParticipant!.publishVideoTrack( track, publishOptions: VideoPublishOptions( simulcast: true, degradationPreference: DegradationPreference.balanced, screenShareEncoding: encoding, ), ); isScreenSharing = true; _currentScreenShareSourceId = sourceId; _screenShareTrack = track; notifyListeners(); unawaited(_applyFpsToAllSimulcastLayers(track)); } catch (e) { debugPrint('[ScreenShare] ERROR: $e'); } } Future _applyFpsToAllSimulcastLayers(LocalVideoTrack track) async { final targetFps = _screenShareEncoding().maxFramerate; for (var attempt = 1; attempt <= 5; attempt++) { await Future.delayed(const Duration(milliseconds: 600)); if (!isScreenSharing || _screenShareTrack != track) return; final sender = track.transceiver?.sender; if (sender == null) continue; final params = sender.parameters; final encodings = params.encodings; if (encodings == null || encodings.isEmpty) continue; var anyUpdated = false; for (final enc in encodings) { if ((enc.maxFramerate ?? 0) < targetFps) { enc.maxFramerate = targetFps; anyUpdated = true; } } if (anyUpdated) await sender.setParameters(params); return; } } Future stopScreenShare() async { final local = _room?.localParticipant; if (local != null) { final toStop = []; for (final pub in local.videoTrackPublications) { if (pub.source == TrackSource.screenShareVideo) { toStop.add(pub.sid); await pub.track?.stop(); } } for (final sid in toStop) { try { await local.removePublishedTrack(sid); } catch (_) {} } } try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {} isScreenSharing = false; _currentScreenShareSourceId = null; _screenShareTrack = null; notifyListeners(); } Future hangUp() async { _stopStatsLogging(); _presenceHeartbeat?.cancel(); _presenceHeartbeat = null; if (_matrixClient != null && _matrixRoomId != null) { await _writePresence(false).catchError((_) {}); } _roomEventListener?.dispose(); _roomEventListener = null; _room?.removeListener(_onRoomChanged); await _room?.disconnect(); _room = null; _displayName = ''; isConnecting = false; error = null; isMuted = false; isCameraOff = false; isScreenSharing = false; isDeafened = false; isVoiceChannel = false; _mutedBeforeDeafen = false; _screenShareTrack = null; _currentScreenShareSourceId = null; _matrixRoomId = null; _matrixClient = null; notifyListeners(); } Future changeQuality(String qualityKey) async { debugPrint('[LiveKit] Streamer changing publish quality to: $qualityKey'); _localQualityKey = qualityKey; final params = qualityPresets[qualityKey]; if (params == null) { notifyListeners(); return; } if (_room == null) { notifyListeners(); return; } final local = _room!.localParticipant; if (local == null) { notifyListeners(); return; } if (!isCameraOff) { await local.setCameraEnabled(false); await Future.delayed(const Duration(milliseconds: 400)); try { await local.setCameraEnabled( true, cameraCaptureOptions: CameraCaptureOptions(params: params), ); } catch (e) { debugPrint('[LiveKit] Camera restart error: $e'); isCameraOff = true; } } if (isScreenSharing && _currentScreenShareSourceId != null) { final sid = _currentScreenShareSourceId!; await stopScreenShare(); await Future.delayed(const Duration(milliseconds: 400)); await startScreenShare(sid); } notifyListeners(); } Future changeAudioInput(String deviceId) async { final participant = _room?.localParticipant; if (participant == null) return; try { final track = await LocalAudioTrack.create(AudioCaptureOptions(deviceId: deviceId)); for (final pub in List.of(participant.audioTrackPublications)) { await participant.removePublishedTrack(pub.sid); } await participant.publishAudioTrack(track); if (isMuted) await participant.setMicrophoneEnabled(false); } catch (e) { debugPrint('[Audio] error: $e'); } } void setRemoteVolume(double volume) { if (_room == null) return; final mute = volume < 0.05; for (final p in _room!.remoteParticipants.values) { for (final pub in p.audioTrackPublications) { pub.track?.mediaStreamTrack.enabled = !mute; } } } void _startStatsLogging() { _stopStatsLogging(); _statsTimer = Timer.periodic(const Duration(seconds: 5), (_) => _logStats()); } void _stopStatsLogging() { _statsTimer?.cancel(); _statsTimer = null; } Future _logStats() async { if (_room == null) return; // Basic stats logging... debugPrint('[Stats] SubQuality: $_subscribeQualityKey | PubQuality: $_localQualityKey'); } }