Files
pyramid/lib/core/voip_manager.dart
T
Bernd Steckmeister 25ed765a03 wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.)
als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen
Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 05:47:18 +02:00

537 lines
17 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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<void> _ensureRenderers() async {
if (_renderersInitialized) return;
try {
await localRenderer.initialize();
await remoteRenderer.initialize();
_renderersInitialized = true;
} catch (e) {
print('[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) {
print('[VOIP] ICE server merge error: $e');
}
return rtc.createPeerConnection(configuration, constraints);
}
@override
Future<void> handleNewCall(CallSession call) async {
print('[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) {
print('[VOIP] State: $state');
_updateInternalStates();
if (state == CallState.kConnected) {
if (!_isCameraMuted) {
print('[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) {
print('[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) {
print('[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) {
print('[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.
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 {}
Future<void> safeAction(Future<dynamic> Function() action) async {
try {
await action();
await Future.delayed(const Duration(milliseconds: 1000));
} catch (e) {
print('[VOIP] Action error: $e');
}
_updateInternalStates();
_updateRenderers();
}
Future<void> toggleMic() async {
if (currentCall == null) return;
_isMicMuted = !_isMicMuted;
notifyListeners();
try {
await currentCall!.setMicrophoneMuted(_isMicMuted);
} catch (e) {
_isMicMuted = !_isMicMuted;
notifyListeners();
}
}
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) {
print('[VOIP] toggleCamera error: $e');
_isCameraMuted = !_isCameraMuted;
notifyListeners();
}
_updateInternalStates();
await _updateRenderers();
}
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);
});
}
Future<void> toggleDeafen() async {
_isDeafened = !_isDeafened;
notifyListeners();
}
Future<void> hangup() async {
if (currentCall == null) return;
await safeAction(() async {
await currentCall!.hangup(reason: CallErrorCode.userHangup);
currentCall = null;
});
}
Future<void> startCall(String roomId, {bool video = false}) async {
final room = client.getRoomById(roomId);
if (room == null) return;
await _ensureRenderers();
_isCameraMuted = !video;
print('[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<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;
@override
Future<List<dynamic>> getSources() => _delegate.getSources();
@override
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) => _delegate.selectAudioOutput(options);
}