3c7eb9ddca
- CallSignalingService/callSignalingProvider und VoiceChannelService/ voiceChannelProvider als einzige oeffentliche Schnittstellen - voip_manager.dart / livekit_call_manager.dart in ihre Module verschoben, Logik unangetastet (nur Klassen-Koepfe, @override, Imports, Provider-Namen) - Alle Konsumenten entkoppelt; Kompositions-Punkt matrix_client.dart als dokumentierte Ausnahme. analyze sauber, 29 Tests gruen, Windows-Start ok. Call-Klick-/Geraetetest steht aus (PROGRESS.md)
562 lines
18 KiB
Dart
562 lines
18 KiB
Dart
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/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;
|
|
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;
|
|
_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<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();
|
|
}
|
|
}
|
|
|
|
// ── 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 {
|
|
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<void> handleMissedCall(CallSession session) async {
|
|
if (currentCall?.callId == session.callId) {
|
|
currentCall = null;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
@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));
|
|
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;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Future<void> 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();
|
|
}
|
|
|
|
@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 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<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 {
|
|
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);
|
|
}
|