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
+3 -10
View File
@@ -3,8 +3,6 @@ import 'package:matrix/matrix.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/livekit_call_manager.dart';
import 'package:pyramid/core/voip_manager.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart';
// ─── Theme state ──────────────────────────────────────────────────────────────
@@ -64,14 +62,9 @@ enum ModalKind { none, settings, search }
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
// ─── Call state ───────────────────────────────────────────────────────────────
final callStateProvider = ChangeNotifierProvider<LiveKitCallManager>((ref) {
return LiveKitCallManager.instance;
});
final voipStateProvider = ChangeNotifierProvider<PyramidVoipManager>((ref) {
return PyramidVoipManager.instance;
});
// Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen:
// `callSignalingProvider` (features/call_signaling/call_signaling_service.dart)
// und `voiceChannelProvider` (features/voice_channel/voice_channel_service.dart).
final incomingCallProvider = StateProvider<CallSession?>((ref) => null);
+3 -1
View File
@@ -9,7 +9,9 @@ import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_database.dart';
import 'package:pyramid/core/auth_log.dart';
import 'package:pyramid/core/soft_logout_guard.dart';
import 'package:pyramid/core/voip_manager.dart';
// Kompositions-Punkt: Einzige Stelle außerhalb des call_signaling-Moduls,
// die die Implementierung (statt der Fassade) kennen darf sie erzeugt sie.
import 'package:pyramid/features/call_signaling/voip_manager.dart';
final matrixClientProvider = FutureProvider<Client>((ref) async {
// Factory-Wahl, Verschlüsselung (SQLCipher), Migration und PRAGMAs stecken
+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;
});
@@ -6,17 +6,25 @@ 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';
class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
/// 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;
@@ -29,11 +37,17 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
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) {
@@ -253,6 +267,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
/// 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) {
@@ -295,6 +310,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
@override
Future<void> registerListeners(CallSession session) async {}
@override
Future<void> safeAction(Future<dynamic> Function() action) async {
try {
await action();
@@ -306,6 +322,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
_updateRenderers();
}
@override
Future<void> toggleMic() async {
if (currentCall == null) return;
_isMicMuted = !_isMicMuted;
@@ -318,6 +335,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
}
}
@override
Future<void> toggleCamera() async {
if (currentCall == null) return;
_isCameraMuted = !_isCameraMuted;
@@ -337,6 +355,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
await _updateRenderers();
}
@override
Future<void> toggleScreenSharing([BuildContext? context]) async {
if (currentCall == null) return;
final call = currentCall!;
@@ -359,11 +378,13 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
});
}
@override
Future<void> toggleDeafen() async {
_isDeafened = !_isDeafened;
notifyListeners();
}
@override
Future<void> hangup() async {
if (currentCall == null) return;
await safeAction(() async {
@@ -372,6 +393,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
});
}
@override
Future<void> startCall(String roomId, {bool video = false}) async {
final room = client.getRoomById(roomId);
if (room == null) return;
@@ -394,6 +416,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
notifyListeners();
}
@override
Future<void> changeQuality(String key) async {
_currentQualityKey = key;
if (currentCall == null) {
+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';
@@ -6,11 +6,14 @@ 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';
class LiveKitCallManager with ChangeNotifier {
/// Implementierung von [VoiceChannelService] (Modul `voice_channel`).
/// Andere Module gehen ausschließlich über `voiceChannelProvider`.
class LiveKitCallManager extends VoiceChannelService {
static final LiveKitCallManager instance = LiveKitCallManager._();
LiveKitCallManager._();
@@ -32,17 +35,24 @@ class LiveKitCallManager with ChangeNotifier {
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;
@@ -50,12 +60,18 @@ class LiveKitCallManager with ChangeNotifier {
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 =>
@@ -122,6 +138,7 @@ class LiveKitCallManager with ChangeNotifier {
}
}
@override
Future<void> startCall({
required String roomName,
required String roomDisplayName,
@@ -307,6 +324,7 @@ class LiveKitCallManager with ChangeNotifier {
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;
@@ -323,6 +341,7 @@ class LiveKitCallManager with ChangeNotifier {
}
}
@override
Future<void> setSubscribeQuality(String key) async {
debugPrint('[LiveKit] Viewer changing subscribe quality to: $key');
_subscribeQualityKey = key;
@@ -340,12 +359,14 @@ class LiveKitCallManager with ChangeNotifier {
notifyListeners();
}
@override
Future<void> toggleMute() async {
isMuted = !isMuted;
notifyListeners();
await _room?.localParticipant?.setMicrophoneEnabled(!isMuted);
}
@override
Future<void> toggleDeafen() async {
if (!isDeafened) {
_mutedBeforeDeafen = isMuted;
@@ -377,6 +398,7 @@ class LiveKitCallManager with ChangeNotifier {
}
}
@override
Future<void> toggleCamera() async {
if (isCameraOff) {
try {
@@ -399,6 +421,7 @@ class LiveKitCallManager with ChangeNotifier {
notifyListeners();
}
@override
Future<void> toggleScreenShare() async {
if (isScreenSharing) {
await stopScreenShare();
@@ -426,6 +449,7 @@ class LiveKitCallManager with ChangeNotifier {
const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15);
}
@override
Future<void> startScreenShare(String sourceId) async {
if (isScreenSharing) return;
try {
@@ -480,6 +504,7 @@ class LiveKitCallManager with ChangeNotifier {
}
}
@override
Future<void> stopScreenShare() async {
final local = _room?.localParticipant;
if (local != null) {
@@ -501,6 +526,7 @@ class LiveKitCallManager with ChangeNotifier {
notifyListeners();
}
@override
Future<void> hangUp() async {
_stopStatsLogging();
_presenceHeartbeat?.cancel();
@@ -529,6 +555,7 @@ class LiveKitCallManager with ChangeNotifier {
notifyListeners();
}
@override
Future<void> changeQuality(String qualityKey) async {
debugPrint('[LiveKit] Streamer changing publish quality to: $qualityKey');
_localQualityKey = qualityKey;
@@ -562,6 +589,7 @@ class LiveKitCallManager with ChangeNotifier {
notifyListeners();
}
@override
Future<void> changeAudioInput(String deviceId) async {
final participant = _room?.localParticipant;
if (participant == null) return;
@@ -575,6 +603,7 @@ class LiveKitCallManager with ChangeNotifier {
} catch (e) { debugPrint('[Audio] error: $e'); }
}
@override
void setRemoteVolume(double volume) {
if (_room == null) return;
final mute = volume < 0.05;
@@ -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;
});
+7 -6
View File
@@ -9,9 +9,10 @@ import 'package:pretty_qr_code/pretty_qr_code.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/voip_manager.dart';
import 'package:pyramid/features/auth/bootstrap_dialog.dart';
import 'package:pyramid/features/call/voice_channel.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/chat/chat_view.dart';
import 'package:pyramid/features/members/members_panel.dart';
import 'package:pyramid/features/rooms/rooms_panel.dart';
@@ -160,7 +161,7 @@ class _AppShellState extends ConsumerState<AppShell>
// notifyListeners (u.a. Render-Timer im Sekundentakt); ohne Flankenerkennung
// würde der Nutzer permanent zurück in die Call-UI gezwungen und könnte
// sie nie minimieren.
ref.listen(voipStateProvider, (prev, next) {
ref.listen(callSignalingProvider, (prev, next) {
final wasConnected = _wasVoipConnected;
final isConnected = next.currentCall != null &&
next.currentCall!.state == CallState.kConnected;
@@ -172,7 +173,7 @@ class _AppShellState extends ConsumerState<AppShell>
}
});
ref.listen(callStateProvider, (prev, next) {
ref.listen(voiceChannelProvider, (prev, next) {
// Only auto-switch to voice view for video/audio calls, NOT voice channels
// (voice channels should stay in background with a mini bar)
final wasActive = _wasLiveKitActive;
@@ -200,7 +201,7 @@ class _AppShellState extends ConsumerState<AppShell>
final activeModal = ref.watch(activeModalProvider);
final membersOpen = ref.watch(membersPanelOpenProvider);
final innerPt = PyramidTheme.of(context);
final voip = ref.watch(voipStateProvider);
final voip = ref.watch(callSignalingProvider);
final size = MediaQuery.sizeOf(context);
final isMobile = size.width < 900 || size.height < 500;
@@ -332,7 +333,7 @@ class _MobileShell extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final voip = ref.watch(voipStateProvider);
final voip = ref.watch(callSignalingProvider);
Widget body;
if (viewMode == ViewMode.voice) {
@@ -456,7 +457,7 @@ class _NotificationBlockedBanner extends ConsumerWidget {
class _IncomingCallOverlay extends ConsumerWidget {
final CallSession call;
final PyramidVoipManager voip;
final CallSignalingService voip;
final PyramidTheme pt;
const _IncomingCallOverlay({
+6 -6
View File
@@ -1,14 +1,14 @@
part of '../settings_modal.dart';
class _VoiceSection extends StatefulWidget {
class _VoiceSection extends ConsumerStatefulWidget {
final PyramidTheme pt;
const _VoiceSection({required this.pt});
@override
State<_VoiceSection> createState() => _VoiceSectionState();
ConsumerState<_VoiceSection> createState() => _VoiceSectionState();
}
class _VoiceSectionState extends State<_VoiceSection> {
class _VoiceSectionState extends ConsumerState<_VoiceSection> {
List<MediaDeviceInfo> _audioInputs = [];
List<MediaDeviceInfo> _audioOutputs = [];
List<MediaDeviceInfo> _videoInputs = [];
@@ -89,10 +89,10 @@ class _VoiceSectionState extends State<_VoiceSection> {
setState(() => _outputVolume = v);
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_kOutputVolume, v);
// Live auf laufende Calls anwenden.
LiveKitCallManager.instance.setOutputVolume(v).catchError((_) {});
// Live auf laufende Calls anwenden (über die Modul-Fassaden).
ref.read(voiceChannelProvider).setOutputVolume(v).catchError((_) {});
try {
PyramidVoipManager.instance.applyOutputVolume(v).catchError((_) {});
ref.read(callSignalingProvider).applyOutputVolume(v).catchError((_) {});
} catch (_) {} // Instanz existiert evtl. noch nicht
}
+2 -2
View File
@@ -24,9 +24,9 @@ import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/auth_log.dart';
import 'package:pyramid/core/e2ee_diagnostics.dart';
import 'package:pyramid/features/auth/bootstrap_dialog.dart' show isKeyBackupMissing;
import 'package:pyramid/core/livekit_call_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/core/matrix_client.dart';
import 'package:pyramid/core/voip_manager.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/update_checker.dart';