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:
+3
-10
@@ -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);
|
||||
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:matrix/matrix.dart' hide Room;
|
||||
import 'package:pyramid/core/ice_servers.dart';
|
||||
import 'package:pyramid/core/livekit_token.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _kVoicePresenceType = 'io.pyramid.voice.presence';
|
||||
|
||||
class LiveKitCallManager with ChangeNotifier {
|
||||
static final LiveKitCallManager instance = LiveKitCallManager._();
|
||||
LiveKitCallManager._();
|
||||
|
||||
// ── Publish quality presets (Streamer → Server) ──────────────────────────
|
||||
static final Map<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 = '';
|
||||
bool isConnecting = false;
|
||||
String? error;
|
||||
bool isMuted = false;
|
||||
bool isCameraOff = false;
|
||||
bool isScreenSharing = false;
|
||||
String _localQualityKey = 'hd';
|
||||
String _subscribeQualityKey = 'auto'; // 'auto' | 'low' | 'medium' | 'high'
|
||||
String? _currentScreenShareSourceId;
|
||||
LocalVideoTrack? _screenShareTrack;
|
||||
bool isDeafened = false;
|
||||
bool _mutedBeforeDeafen = false;
|
||||
bool isVoiceChannel = false;
|
||||
String? _matrixRoomId;
|
||||
Client? _matrixClient;
|
||||
Timer? _presenceHeartbeat;
|
||||
|
||||
Timer? _statsTimer;
|
||||
|
||||
bool get isActive => _room != null || isConnecting;
|
||||
Room? get room => _room;
|
||||
String? get currentRoomId => _matrixRoomId;
|
||||
String get displayName => _displayName;
|
||||
bool get noiseSuppression => false;
|
||||
String get currentQualityKey => _localQualityKey;
|
||||
String get subscribeQualityKey => _subscribeQualityKey;
|
||||
|
||||
VideoParameters get _currentQuality =>
|
||||
qualityPresets[_localQualityKey] ?? VideoParametersPresets.h720_169;
|
||||
|
||||
void _onRoomChanged() => notifyListeners();
|
||||
|
||||
void _cleanUpAfterDisconnect() {
|
||||
_stopStatsLogging();
|
||||
_presenceHeartbeat?.cancel();
|
||||
_presenceHeartbeat = null;
|
||||
if (_matrixClient != null && _matrixRoomId != null) {
|
||||
_writePresence(false).catchError((_) {});
|
||||
}
|
||||
_roomEventListener?.dispose();
|
||||
_roomEventListener = null;
|
||||
_room?.removeListener(_onRoomChanged);
|
||||
_room = null;
|
||||
_displayName = '';
|
||||
isConnecting = false;
|
||||
error = null;
|
||||
isMuted = false;
|
||||
isCameraOff = false;
|
||||
isScreenSharing = false;
|
||||
isDeafened = false;
|
||||
isVoiceChannel = false;
|
||||
_mutedBeforeDeafen = false;
|
||||
_screenShareTrack = null;
|
||||
_currentScreenShareSourceId = null;
|
||||
_matrixRoomId = null;
|
||||
_matrixClient = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<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');
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
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 (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
Future<void> toggleMute() async {
|
||||
isMuted = !isMuted;
|
||||
notifyListeners();
|
||||
await _room?.localParticipant?.setMicrophoneEnabled(!isMuted);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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'); }
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,538 +0,0 @@
|
||||
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/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) {
|
||||
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.
|
||||
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) {
|
||||
debugPrint('[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) {
|
||||
debugPrint('[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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user