refactor: M2-Call-Pilot – Module call_signaling + voice_channel mit Fassaden (Entscheidung 2)

- 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)
This commit is contained in:
Bernd Steckmeister
2026-07-06 12:42:59 +02:00
parent f64398d3c6
commit 3c7eb9ddca
20 changed files with 383 additions and 53 deletions
+8 -8
View File
@@ -3,14 +3,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/livekit_call_manager.dart';
import 'package:pyramid/core/voip_manager.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/widgets/screen_share_picker.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:livekit_client/livekit_client.dart';
class MiniCallWidget extends ConsumerStatefulWidget {
final dynamic call; // Can be LiveKitCallManager or PyramidVoipManager
final dynamic call; // CallSignalingService (1:1) oder VoiceChannelService
const MiniCallWidget({super.key, required this.call});
@override
@@ -45,7 +45,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final isVoip = widget.call is PyramidVoipManager;
final isVoip = widget.call is CallSignalingService;
final call = widget.call;
final String displayName = isVoip
@@ -95,7 +95,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
isStreaming ? call.localRenderer : call.remoteRenderer,
objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
)
: _LiveKitPreview(manager: call as LiveKitCallManager),
: _LiveKitPreview(manager: call as VoiceChannelService),
),
// Top bar of preview
@@ -259,9 +259,9 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
pt: pt,
onTap: () async {
if (isVoip) {
(call as PyramidVoipManager).toggleScreenSharing(context);
(call as CallSignalingService).toggleScreenSharing(context);
} else {
final lk = call as LiveKitCallManager;
final lk = call as VoiceChannelService;
if (lk.isScreenSharing) {
await lk.stopScreenShare();
} else {
@@ -351,7 +351,7 @@ class _ControlBtnState extends State<_ControlBtn> {
}
class _LiveKitPreview extends StatelessWidget {
final LiveKitCallManager manager;
final VoiceChannelService manager;
const _LiveKitPreview({required this.manager});
@override
+9 -9
View File
@@ -6,8 +6,8 @@ import 'package:collection/collection.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/widgets/screen_share_picker.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/livekit_call_manager.dart';
import 'package:pyramid/core/voip_manager.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/pyramid_loader.dart';
@@ -45,8 +45,8 @@ class VoiceChannelView extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
final call = ref.watch(callStateProvider);
final voip = ref.watch(voipStateProvider);
final call = ref.watch(voiceChannelProvider);
final voip = ref.watch(callSignalingProvider);
if (voip.currentCall != null && voip.currentCall!.state != CallState.kEnded) {
return _UnifiedCallView(
@@ -101,7 +101,7 @@ class VoiceChannelView extends ConsumerWidget {
);
}
List<_ParticipantData> _getLiveKitParticipants(LiveKitCallManager call) {
List<_ParticipantData> _getLiveKitParticipants(VoiceChannelService call) {
final room = call.room;
if (room == null) return [];
final all = <Participant>[
@@ -142,7 +142,7 @@ class VoiceChannelView extends ConsumerWidget {
}).toList();
}
List<_ParticipantData> _getVoipParticipants(PyramidVoipManager voip) {
List<_ParticipantData> _getVoipParticipants(CallSignalingService voip) {
final participants = <_ParticipantData>[];
double ar(rtc.RTCVideoRenderer r) {
@@ -997,7 +997,7 @@ class _FullscreenVideoPage extends StatelessWidget {
}
}
void _handleLiveKitScreenShare(BuildContext context, LiveKitCallManager call) async {
void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async {
if (call.isScreenSharing) {
await call.stopScreenShare();
} else {
@@ -1006,7 +1006,7 @@ void _handleLiveKitScreenShare(BuildContext context, LiveKitCallManager call) as
}
}
void _showLiveKitQualityMenu(BuildContext context, LiveKitCallManager call, Offset pos) {
void _showLiveKitQualityMenu(BuildContext context, VoiceChannelService call, Offset pos) {
final pt = PyramidTheme.of(context);
showMenu(
context: context,
@@ -1025,7 +1025,7 @@ void _showLiveKitQualityMenu(BuildContext context, LiveKitCallManager call, Offs
);
}
void _showVoipQualityMenu(BuildContext context, PyramidVoipManager voip, Offset pos) {
void _showVoipQualityMenu(BuildContext context, CallSignalingService voip, Offset pos) {
final pt = PyramidTheme.of(context);
showMenu(
context: context,
@@ -0,0 +1,67 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:matrix/matrix.dart';
import 'package:pyramid/features/call_signaling/voip_manager.dart';
/// Öffentliche Schnittstelle des Moduls `call_signaling` natives
/// Matrix-1:1-VoIP (`m.call.*`-Events, direkte WebRTC-Peer-Connection,
/// kein SFU). Schnitt nach `docs/M2_MODULE_SCHNITT.md`.
///
/// Andere Module (UI, Settings, Shell) konsumieren AUSSCHLIESSLICH diese
/// Schnittstelle über [callSignalingProvider] nie `voip_manager.dart`
/// direkt. Einzige Ausnahme ist der Kompositions-Punkt in
/// `matrix_client.dart`, der die Implementierung konstruiert.
///
/// Bewusste Abweichungen vom Entwurf im Doc (Verhaltensgleichheit vor
/// Schönheit, heiliger Call-Pfad):
/// * ChangeNotifier statt Streams die gesamte UI hängt heute an
/// `ref.watch(...)` mit notifyListeners; ein Stream-Umbau wäre eine
/// Verhaltensänderung ohne Not.
/// * [currentCall] bleibt als matrix-SDK-`CallSession` sichtbar (die UI
/// braucht state/room/answer/reject), ebenso die WebRTC-Renderer
/// das Doc sieht Letzteres ausdrücklich vor.
abstract class CallSignalingService extends ChangeNotifier {
/// Der laufende 1:1-Call (null = keiner). UI liest state/room und
/// beantwortet eingehende Calls über [safeAction] + answer()/reject().
CallSession? get currentCall;
rtc.RTCVideoRenderer get localRenderer;
rtc.RTCVideoRenderer get remoteRenderer;
bool get isMicMuted;
bool get isCameraMuted;
bool get isScreensharing;
bool get isRemoteScreensharing;
bool get isDeafened;
/// Sende-Qualität ('sd' | 'hd' | 'fhd' | '4k').
String get currentQualityKey;
Future<void> startCall(String roomId, {bool video});
Future<void> hangup();
Future<void> toggleMic();
Future<void> toggleCamera();
Future<void> toggleDeafen();
/// Auf Windows öffnet ein [context] den Quellen-Picker fürs Screensharing.
Future<void> toggleScreenSharing([BuildContext? context]);
Future<void> changeQuality(String key);
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
Future<void> applyOutputVolume([double? value]);
/// Führt eine Call-Aktion (answer/reject/…) fehlertolerant aus und stößt
/// danach die interne Zustands-/Renderer-Aktualisierung an.
Future<void> safeAction(Future<dynamic> Function() action);
}
/// Einziger Zugriffsweg der UI auf das 1:1-VoIP-Modul. Die Instanz wird beim
/// Client-Aufbau in `matrix_client.dart` erzeugt (existiert also, sobald die
/// eingeloggte Shell steht).
final callSignalingProvider =
ChangeNotifierProvider<CallSignalingService>((ref) {
return PyramidVoipManager.instance;
});
@@ -0,0 +1,561 @@
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);
}
+2 -1
View File
@@ -22,7 +22,8 @@ import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/core/notification_service.dart';
import 'package:pyramid/core/voip_manager.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
part 'view/chat_view_core.dart';
+3 -3
View File
@@ -167,13 +167,13 @@ class _ChatHeader extends ConsumerWidget {
if (room.isDirectChat) {
// Use native Matrix VoIP for 1:1 DMs
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
await PyramidVoipManager.instance.startCall(room.id, video: cam);
await ref.read(callSignalingProvider).startCall(room.id, video: cam);
return;
}
// Use LiveKit for group voice rooms
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
await ref.read(callStateProvider).startCall(
await ref.read(voiceChannelProvider).startCall(
roomName: roomId,
roomDisplayName: room.getLocalizedDisplayname(),
identity: 'user_${DateTime.now().millisecondsSinceEpoch}',
@@ -576,7 +576,7 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
void _startCall({required bool cam}) async {
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
await PyramidVoipManager.instance.startCall(widget.room.id, video: cam);
await ref.read(callSignalingProvider).startCall(widget.room.id, video: cam);
}
}
@@ -39,8 +39,8 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
final pt = PyramidTheme.of(context);
final roomsAsync = ref.watch(roomListProvider);
final activeSpaceId = ref.watch(activeSpaceIdProvider);
final call = ref.watch(callStateProvider);
final voip = ref.watch(voipStateProvider);
final call = ref.watch(voiceChannelProvider);
final voip = ref.watch(callSignalingProvider);
final isRealSpace = activeSpaceId != null &&
activeSpaceId != 'dms' &&
@@ -95,7 +95,7 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
if (jr.isVoice) {
// LiveKit only needs the room id + a display name, so connect even if the
// matrix room object hasn't synced locally yet.
final call = ref.read(callStateProvider);
final call = ref.read(voiceChannelProvider);
if (call.isActive) await call.hangUp();
ref.read(activeVoiceRoomIdProvider.notifier).state = jr.roomId;
await call.startCall(
@@ -199,7 +199,7 @@ class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
}
void joinVoiceChannel(Room room) async {
final call = ref.read(callStateProvider);
final call = ref.read(voiceChannelProvider);
final activeVoice = ref.read(activeVoiceRoomIdProvider);
// Toggle: leave if already in this channel
if (call.isVoiceChannel && activeVoice == room.id) {
+2
View File
@@ -11,6 +11,8 @@ import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart';
import 'package:pyramid/features/call/mini_call_widget.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/features/rooms/user_panel.dart';
import 'package:pyramid/features/spaces/space_admin_dialog.dart';
import 'package:pyramid/widgets/create_join_dialog.dart';
@@ -0,0 +1,632 @@
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:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _kVoicePresenceType = 'io.pyramid.voice.presence';
/// Implementierung von [VoiceChannelService] (Modul `voice_channel`).
/// Andere Module gehen ausschließlich über `voiceChannelProvider`.
class LiveKitCallManager extends VoiceChannelService {
static final LiveKitCallManager instance = LiveKitCallManager._();
LiveKitCallManager._();
// ── Publish quality presets (Streamer → Server) ──────────────────────────
static final Map<String, VideoParameters> qualityPresets = {
'sd': VideoParametersPresets.h360_169,
'hd': VideoParametersPresets.h720_169,
'fhd': VideoParametersPresets.h1080_169,
'4k': VideoParametersPresets.h2160_169,
};
static const Map<String, VideoEncoding> _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<RoomEvent>? _roomEventListener;
String _displayName = '';
@override
bool isConnecting = false;
@override
String? error;
@override
bool isMuted = false;
@override
bool isCameraOff = false;
@override
bool isScreenSharing = false;
String _localQualityKey = 'hd';
String _subscribeQualityKey = 'auto'; // 'auto' | 'low' | 'medium' | 'high'
String? _currentScreenShareSourceId;
LocalVideoTrack? _screenShareTrack;
@override
bool isDeafened = false;
bool _mutedBeforeDeafen = false;
@override
bool isVoiceChannel = false;
String? _matrixRoomId;
Client? _matrixClient;
Timer? _presenceHeartbeat;
Timer? _statsTimer;
@override
bool get isActive => _room != null || isConnecting;
@override
Room? get room => _room;
@override
String? get currentRoomId => _matrixRoomId;
@override
String get displayName => _displayName;
bool get noiseSuppression => false;
@override
String get currentQualityKey => _localQualityKey;
@override
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<void> _writePresence(bool active) async {
final client = _matrixClient;
final roomId = _matrixRoomId;
final userId = client?.userID;
if (client == null || roomId == null || userId == null) return;
try {
Map<String, dynamic> 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');
}
}
@override
Future<void> 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<String>.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<LocalTrackPublishedEvent>((_) => notifyListeners())
..on<LocalTrackUnpublishedEvent>((event) {
if (event.publication.source == TrackSource.screenShareVideo) {
isScreenSharing = false;
_currentScreenShareSourceId = null;
_screenShareTrack = null;
}
notifyListeners();
})
..on<TrackSubscribedEvent>((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<TrackUnsubscribedEvent>((_) => notifyListeners())
..on<TrackPublishedEvent>((_) => notifyListeners())
..on<TrackUnpublishedEvent>((_) => notifyListeners())
..on<ParticipantConnectedEvent>((_) => notifyListeners())
..on<ParticipantDisconnectedEvent>((_) => notifyListeners())
..on<RoomDisconnectedEvent>((_) {
_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 = <String>{};
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.
@override
Future<void> 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 (_) {}
}
}
}
}
@override
Future<void> 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();
}
@override
Future<void> toggleMute() async {
isMuted = !isMuted;
notifyListeners();
await _room?.localParticipant?.setMicrophoneEnabled(!isMuted);
}
@override
Future<void> 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;
}
}
}
}
@override
Future<void> 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();
}
@override
Future<void> 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);
}
@override
Future<void> 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<void> _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;
}
}
@override
Future<void> stopScreenShare() async {
final local = _room?.localParticipant;
if (local != null) {
final toStop = <String>[];
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();
}
@override
Future<void> 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();
}
@override
Future<void> 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();
}
@override
Future<void> 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'); }
}
@override
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<void> _logStats() async {
if (_room == null) return;
// Basic stats logging...
debugPrint('[Stats] SubQuality: $_subscribeQualityKey | PubQuality: $_localQualityKey');
}
}
@@ -0,0 +1,74 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:matrix/matrix.dart' hide Room;
import 'package:pyramid/features/voice_channel/livekit_call_manager.dart';
/// Öffentliche Schnittstelle des Moduls `voice_channel` LiveKit-SFU-basierte,
/// dauerhafte Voice-Channels (Discord-artig: Presence über Matrix-State-Events,
/// Screensharing, Mehrpersonen). Schnitt nach `docs/M2_MODULE_SCHNITT.md`;
/// bewusst GETRENNT vom 1:1-Modul `call_signaling`, siehe dort.
///
/// Andere Module konsumieren ausschließlich diese Schnittstelle über
/// [voiceChannelProvider] nie `livekit_call_manager.dart` direkt.
///
/// Bewusste Abweichungen vom Entwurf im Doc (Verhaltensgleichheit vor
/// Schönheit): ChangeNotifier statt `stateStream`, Methodennamen wie im
/// Bestand (`startCall`/`hangUp` statt `join`/`leave`), und [room] bleibt
/// als LiveKit-Typ sichtbar, weil die Teilnehmer-Kacheln der UI direkt
/// LiveKit-Tracks rendern (analog zu den WebRTC-Renderern im Signaling).
abstract class VoiceChannelService extends ChangeNotifier {
bool get isActive;
bool get isConnecting;
String? get error;
bool get isMuted;
bool get isCameraOff;
bool get isScreenSharing;
bool get isDeafened;
bool get isVoiceChannel;
/// Der verbundene LiveKit-Raum (null = nicht verbunden).
lk.Room? get room;
/// Matrix-Raum-ID des aktiven Voice-Channels.
String? get currentRoomId;
String get displayName;
/// Sende-Qualität ('sd' | 'hd' | 'fhd' | '4k').
String get currentQualityKey;
/// Empfangs-Qualität ('auto' | 'low' | 'medium' | 'high').
String get subscribeQualityKey;
Future<void> startCall({
required String roomName,
required String roomDisplayName,
required String identity,
bool audioOnly,
bool voiceChannel,
Client? matrixClient,
String? matrixRoomId,
});
Future<void> hangUp();
Future<void> toggleMute();
Future<void> toggleDeafen();
Future<void> toggleCamera();
Future<void> toggleScreenShare();
Future<void> startScreenShare(String sourceId);
Future<void> stopScreenShare();
Future<void> changeQuality(String qualityKey);
Future<void> setSubscribeQuality(String key);
/// Live-Anwendung des Lautstärke-Sliders auf alle Remote-Audio-Tracks.
Future<void> setOutputVolume(double v);
Future<void> changeAudioInput(String deviceId);
void setRemoteVolume(double volume);
}
/// Einziger Zugriffsweg der UI auf das Voice-Channel-Modul.
final voiceChannelProvider =
ChangeNotifierProvider<VoiceChannelService>((ref) {
return LiveKitCallManager.instance;
});