Bernds Anruftest Handy↔PC (2026-10-07): - Teilen am Handy: App "verkleinerte sich", Anruf danach weg. Ursache: Android 14+ verlangt vor MediaProjection einen Vordergrunddienst vom Typ mediaProjection; flutter_webrtc startet keinen, der Manifest-Eintrag verwies auf eine nicht mehr existierende Klasse. Neu: CallService (microphone|mediaProjection) + CallForeground: Zustimmung → Dienst (wartet auf echten startForeground) → Mitschnitt. Emulator-Test (Android 16): mit Fix 1080x2400@30 läuft; Gegenprobe ohne Dienst wirft genau 'Media projections require a foreground service ...'. - Dienst läuft während jedes Anrufs: Mikrofon bleibt im Hintergrund aktiv, Benachrichtigung "Anruf läuft" führt zurück. Mikrofon-Typ nur mit Berechtigung, sonst schmalere Variante statt Dienst-Abbruch. - Handy hörte nichts: SDK schaltet Sprachanrufe auf die Hörmuschel. Jetzt Lautsprecher als Standard (wie Discord) + Umschalter in der Anrufleiste. - LiveKit-Teilen am Handy öffnete den PC-Quellen-Auswähler → System-Dialog. - Im offenen Chat (Handy) Leiste "Anruf läuft · Zurück". - Einstellungen → Sprache & Video: Mikrofontest mit Pegel (PC-Diagnose: das Standardgerät lieferte nur Stille – Voicemeeter/virtuelle Geräte). Co-Authored-By: Claude Opus 5.5 <[email protected]>
613 lines
19 KiB
Dart
613 lines
19 KiB
Dart
import 'dart:async';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||
import 'package:matrix/matrix.dart';
|
||
import 'package:webrtc_interface/webrtc_interface.dart';
|
||
import 'package:pyramid/core/call_foreground.dart';
|
||
import 'package:pyramid/core/ice_servers.dart';
|
||
import 'package:pyramid/core/settings_prefs.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;
|
||
|
||
// Handy: Lautsprecher statt Hörmuschel (wie Discord). Das SDK schaltet bei
|
||
// Sprachanrufen auf die Hörmuschel – hält man das Handy vor sich, hört man
|
||
// dann praktisch nichts (Bernds Test 2026-10-07).
|
||
bool _speakerOn = true;
|
||
bool get _isMobile =>
|
||
!kIsWeb &&
|
||
(defaultTargetPlatform == TargetPlatform.android ||
|
||
defaultTargetPlatform == TargetPlatform.iOS);
|
||
|
||
@override
|
||
bool get isSpeakerOn => _speakerOn;
|
||
|
||
@override
|
||
Future<void> toggleSpeaker() async {
|
||
_speakerOn = !_speakerOn;
|
||
notifyListeners();
|
||
await _applySpeaker();
|
||
}
|
||
|
||
Future<void> _applySpeaker() async {
|
||
if (!_isMobile) return;
|
||
try {
|
||
await rtc.Helper.setSpeakerphoneOn(_speakerOn);
|
||
} catch (e) {
|
||
debugPrint('[VOIP] Lautsprecher: $e');
|
||
}
|
||
}
|
||
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;
|
||
_speakerOn = true;
|
||
_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);
|
||
}
|
||
_applySpeaker();
|
||
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
|
||
CallForeground.callStarted('voip',
|
||
title: call.room.getLocalizedDisplayname());
|
||
}
|
||
if (state == CallState.kEnded) CallForeground.callEnded('voip');
|
||
_updateRenderers();
|
||
});
|
||
// Das SDK setzt bei jedem neuen eigenen Medienstrom die Hörmuschel –
|
||
// danach unsere Wahl erneut anwenden.
|
||
call.onCallEventChanged.stream.listen((event) {
|
||
if (event == CallStateChange.kFeedsChanged) _applySpeaker();
|
||
});
|
||
|
||
_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();
|
||
}
|
||
CallForeground.callEnded('voip');
|
||
}
|
||
|
||
// ── 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 {
|
||
v = await VoicePrefs.loadOutputVolume();
|
||
} 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();
|
||
}
|
||
CallForeground.callEnded('voip');
|
||
}
|
||
|
||
@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));
|
||
await CallForeground.screenCaptureStopped('voip');
|
||
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;
|
||
});
|
||
await CallForeground.callEnded('voip');
|
||
}
|
||
|
||
@override
|
||
Future<void> startCall(String roomId, {bool video = false}) async {
|
||
final room = client.getRoomById(roomId);
|
||
if (room == null) return;
|
||
await _ensureRenderers();
|
||
_isCameraMuted = !video;
|
||
|
||
_speakerOn = true;
|
||
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 devicePrefs = await VoicePrefs.loadDevices();
|
||
final mic = devicePrefs.mic;
|
||
final cam = devicePrefs.camera;
|
||
final speaker = devicePrefs.speaker;
|
||
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 {
|
||
// Android 14+: ohne laufenden mediaProjection-Dienst stürzt die App beim
|
||
// Mitschnitt ab – erst Zustimmung, dann Dienst, dann Mitschnitt.
|
||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||
if (!await CallForeground.prepareScreenCapture('voip')) {
|
||
throw Exception('Bildschirmübertragung wurde nicht erlaubt');
|
||
}
|
||
}
|
||
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);
|
||
}
|