Files
pyramid/lib/features/call_signaling/voip_manager.dart
T
Bernd SteckmeisterandClaude Opus 5.5 8074928505 fix(calls): Bildschirm teilen sichtbar, Verbindung trotz SDK-Wettlauf, Sprech-Ring; Trenner in Gruppen
Bernds Test 2026-10-08 (Streamen ging weder am PC noch am Handy, Ton kam
nicht an, Trenner blieb manchmal stehen) – mit neuem Live-Anruftest
(zwei Testkonten im selben Prozess) nachgestellt und behoben:

- Bildschirm teilen: Die Stromzuordnung rief ein SDK-getFeeds() auf, das es
  nicht gibt; der Fehler wurde geschluckt → Übertragungen wurden nie
  angezeigt und beim Sender nie als aktiv erkannt. Jetzt typisierte
  Zugriffe (remote/localScreenSharingStream, 'stopped' beachtet).
- Verbindungsaufbau: matrix-SDK verwirft Kandidaten, die vor der fertig
  verarbeiteten Einladung bzw. während des Annehmens eintreffen; unter
  Windows wirft getRemoteDescription zudem statt null zu liefern. Pyramid
  puffert alle Kandidaten der Gegenseite und reicht sie direkt an die
  Verbindung nach. Test: vorher nie verbunden, jetzt ~0,5 s (angerufen)
  bzw. ~2,7 s (anrufend), Ton beide Richtungen, Teilen beide Richtungen.
- SDK-Aufrufe in eigener Zone: harmlose Plugin-Fehler (Lautsprecher unter
  Windows) werden protokolliert statt durchzuschlagen; Ergebnisse/Fehler
  per Completer über die Zonengrenze (sonst ewiges Warten).
- Sprech-Ring auch bei 1:1-Anrufen (Pegel aus WebRTC-Statistiken) – zeigt,
  ob das eigene Mikrofon Ton liefert und ob die Gegenseite ankommt; großes
  Bild springt nicht mehr bei jedem Sprechwechsel.
- „Neue Nachrichten“ mitten in einer Gruppe (mehrere Nachrichten desselben
  Absenders) blieb bis zum erneuten Öffnen stehen: Inline-Trenner jetzt nur
  mit Ungelesenem, blendet aus und trägt den Sprungschlüssel. Live-Test.
- „Anruf läuft“-Leiste nicht schon beim Klingeln.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 11:05:46 +02:00

758 lines
25 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
late final Zone _sdkZone;
@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;
// VOR dem SDK anmelden: Kandidaten der Gegenseite puffern (siehe
// _bufferRemoteCandidates).
_callEventSub = client.onCallEvents.stream.listen(_bufferRemoteCandidates);
// Alles, was das Anruf-SDK anstößt, läuft in einer eigenen Zone: Fehler,
// die SDK/WebRTC-Plugin ins Leere werfen (z. B. Lautsprecher-Umschalten
// unter Windows, getRemoteDescription wirft statt null), werden
// protokolliert statt als „unbehandelt“ durchzuschlagen.
_sdkZone = Zone.current.fork(
specification: ZoneSpecification(
handleUncaughtError: (self, parent, zone, error, stack) {
debugPrint('[VOIP] SDK-Fehler (abgefangen): $error');
},
),
);
voip = _sdkZone.run(() => VoIP(client, this));
// Suppress speakerphone call — not supported on Windows/Desktop
try {
(voip as dynamic).onConfigSpeakerphone = (bool _) async {};
} catch (_) {}
_ensureRenderers();
}
/// Führt [f] in der SDK-Zone aus. Ergebnis UND Fehler kommen über einen
/// Completer von außen zurück – Fehler können Zonengrenzen sonst nicht
/// überqueren, und der Aufrufer würde ewig warten.
Future<T> _inSdkZone<T>(Future<T> Function() f) {
final done = Completer<T>();
_sdkZone.run(() async {
try {
done.complete(await f());
} catch (e, s) {
done.completeError(e, s);
}
});
return done.future;
}
@override
void dispose() {
_callEventSub?.cancel();
_levelTimer?.cancel();
_renderRefreshTimer?.cancel();
super.dispose();
}
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();
unawaited(_applyBufferedCandidates(call));
}
// ── Netzwerk-Kandidaten der Gegenseite puffern ───────────────────────────
// Lücke im matrix-SDK (6.2): Kommen die Kandidaten des Anrufers an, bevor
// die Einladung fertig verarbeitet ist, verwirft das SDK sie endgültig
// („we do not have the call“); Kandidaten, die WÄHREND des Annehmens
// eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet
// wird. Dann kennt man keinen Weg zur Gegenseite – im Heimnetz rettet das
// oft noch ein direkter Prüfversuch der anderen Seite, über Mobilfunk/NAT
// dauert der Aufbau ewig oder scheitert (Bernd 2026-10-08, im Anruftest
// nachgestellt). Deshalb: alle Kandidaten mitschreiben und direkt an die
// Verbindung geben, sobald sie die Gegenseite kennt. Doppelte ignoriert
// WebRTC.
StreamSubscription<List<BasicEventWithSender>>? _callEventSub;
final _candidateBuffer = <String, List<Map<String, dynamic>>>{};
final _appliedCandidates = <String>{};
void _bufferRemoteCandidates(List<BasicEventWithSender> events) {
for (final e in events) {
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
continue;
}
final callId = e.content.tryGet<String>('call_id');
final list = e.content.tryGetList<Map<String, dynamic>>('candidates');
if (callId == null || list == null) continue;
final party = e.content.tryGet<String>('party_id');
(_candidateBuffer[callId] ??= []).addAll(
list.map((c) => {...c, '_party': party}),
);
// Nur die letzten Anrufe behalten.
while (_candidateBuffer.length > 8) {
_candidateBuffer.remove(_candidateBuffer.keys.first);
}
final call = currentCall;
if (call != null && call.callId == callId) {
unawaited(_applyBufferedCandidates(call));
}
}
}
Future<void> _applyBufferedCandidates(CallSession call) async {
final pc = call.pc;
final list = _candidateBuffer[call.callId];
if (pc == null || list == null || list.isEmpty) return;
try {
if (await pc.getRemoteDescription() == null) return;
} catch (_) {
return;
}
for (final c in List.of(list)) {
final cand = c['candidate'];
if (cand is! String || cand.isEmpty) continue;
final party = c['_party'];
final remoteParty = call.remotePartyId;
if (remoteParty != null && party != null && party != remoteParty) continue;
if (!_appliedCandidates.add('${call.callId}|$cand')) continue;
try {
await pc.addCandidate(rtc.RTCIceCandidate(
cand,
c['sdpMid']?.toString() ?? '',
(c['sdpMLineIndex'] as num?)?.round() ?? 0,
));
} catch (e) {
debugPrint('[VOIP] Kandidat nachreichen: $e');
}
}
}
Timer? _renderRefreshTimer;
String? _listenedCallId;
void _setupCallListeners(CallSession call) {
// Ausgehende Anrufe meldet das SDK zusätzlich über handleNewCall –
// Listener nur einmal je Anruf anhängen.
if (_listenedCallId == call.callId) return;
_listenedCallId = call.callId;
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();
_startLevelPolling();
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
CallForeground.callStarted('voip',
title: call.room.getLocalizedDisplayname());
}
if (state == CallState.kEnded) {
_stopLevelPolling();
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();
// Gepufferte Kandidaten nachreichen, sobald die Verbindung die
// Gegenseite kennt (nach Annehmen bzw. Antwort).
final c = currentCall;
if (c != null && c.state != CallState.kConnected) {
_applyBufferedCandidates(c);
}
},
);
_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 {
final call = currentCall;
if (call == null || _isUpdating) return;
_isUpdating = true;
try {
await _ensureRenderers();
// Typisierte SDK-Zugriffe. Früher lief das über ein dynamisches
// `getFeeds()` – das gibt es im SDK gar nicht; der Fehler wurde
// geschluckt, und Bildschirmübertragungen wurden weder beim Empfänger
// angezeigt noch beim Sender als aktiv erkannt (Bernd 2026-10-08).
rtc.MediaStream? video(WrappedMediaStream? w, {bool respectMute = false}) {
final s = w?.stream;
// stopped: Gegenseite hat die Übertragung beendet (das SDK lässt den
// Strom stehen und markiert ihn nur).
if (w == null || w.stopped || s == null || s.getVideoTracks().isEmpty) {
return null;
}
if (respectMute && w.videoMuted) return null;
return s;
}
final remoteScreen = video(call.remoteScreenSharingStream);
final localScreen = video(call.localScreenSharingStream);
final remoteUser = call.remoteUserMediaStream?.stream;
var changed = false;
final remoteSharing = remoteScreen != null;
final localSharing = call.localScreenSharingStream != null;
if (remoteSharing != _isRemoteScreensharing ||
localSharing != _isScreensharing) {
changed = true;
}
_isRemoteScreensharing = remoteSharing;
_isScreensharing = localSharing;
// Ausgabelautstärke (Settings-Slider) auf den Ton der Gegenseite –
// nur einmal pro Stream, _updateRenderers läuft auch per Timer.
if (remoteUser != null && remoteUser.id != _volumeAppliedStreamId) {
_volumeAppliedStreamId = remoteUser.id;
_remoteAudioStream = remoteUser;
applyOutputVolume();
}
// Bildschirm hat Vorrang vor der Kamera.
final remoteVideo =
remoteScreen ?? video(call.remoteUserMediaStream, respectMute: true);
final localVideo =
localScreen ?? video(call.localUserMediaStream, respectMute: true);
if (remoteRenderer.srcObject?.id != remoteVideo?.id) {
debugPrint('[VOIP] Remote-Video: ${remoteVideo?.id} (Bildschirm: $remoteSharing)');
remoteRenderer.srcObject = remoteVideo;
changed = true;
}
if (localRenderer.srcObject?.id != localVideo?.id) {
debugPrint('[VOIP] Lokales Video: ${localVideo?.id} (Bildschirm: $localSharing)');
localRenderer.srcObject = localVideo;
changed = true;
}
if (changed) notifyListeners();
} catch (e) {
debugPrint('[VOIP] Error in _updateRenderers: $e');
} finally {
_isUpdating = false;
}
}
// ── Wer spricht gerade? (Pegel aus den WebRTC-Statistiken) ───────────────
// Zeigt den grünen Sprech-Ring wie bei Discord – und macht sichtbar, wo
// Ton verloren geht: leuchtet „Du“ beim Sprechen nicht, kommt vom eigenen
// Mikrofon nichts an; leuchtet die Gegenseite nicht, kommt nichts an.
Timer? _levelTimer;
bool _localSpeaking = false;
bool _remoteSpeaking = false;
@override
bool get isLocalSpeaking => _localSpeaking;
@override
bool get isRemoteSpeaking => _remoteSpeaking;
void _startLevelPolling() {
_levelTimer?.cancel();
_levelTimer = Timer.periodic(
const Duration(milliseconds: 300),
(_) => _pollLevels(),
);
}
void _stopLevelPolling() {
_levelTimer?.cancel();
_levelTimer = null;
_localSpeaking = false;
_remoteSpeaking = false;
}
Future<void> _pollLevels() async {
final pc = currentCall?.pc;
if (pc == null) return;
try {
double local = 0;
double remote = 0;
for (final r in await pc.getStats()) {
if (r.values['kind'] != 'audio') continue;
final level = (r.values['audioLevel'] as num?)?.toDouble() ?? 0;
if (r.type == 'media-source' && level > local) local = level;
if (r.type == 'inbound-rtp' && level > remote) remote = level;
}
// Hysterese: an ab 0,03, aus unter 0,01 – kein Flackern.
bool speaking(bool was, double level) => was ? level > 0.01 : level > 0.03;
final ls = speaking(_localSpeaking, _isMicMuted ? 0 : local);
final rs = speaking(_remoteSpeaking, remote);
if (ls != _localSpeaking || rs != _remoteSpeaking) {
_localSpeaking = ls;
_remoteSpeaking = rs;
notifyListeners();
}
} catch (_) {}
}
@override
Future<void> handleCallEnded(CallSession session) async {
if (currentCall?.callId == session.callId) {
_renderRefreshTimer?.cancel();
_renderRefreshTimer = null;
_stopLevelPolling();
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 _inSdkZone(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 {
final c = currentCall!;
await _inSdkZone(() => c.setMicrophoneMuted(_isMicMuted));
} catch (e) {
_isMicMuted = !_isMicMuted;
notifyListeners();
}
}
@override
Future<void> toggleCamera() async {
if (currentCall == null) return;
_isCameraMuted = !_isCameraMuted;
notifyListeners();
try {
final c = currentCall!;
await _inSdkZone(() => c.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 _inSdkZone(
() => voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice));
currentCall = call;
_setupCallListeners(call);
if (video) {
await Future.delayed(const Duration(milliseconds: 800));
await _inSdkZone(() => 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);
}