Bernds Test 2026-10-08: - Taub-Knopf setzte bei 1:1-Anrufen nur ein Häkchen. Jetzt wie Discord: Ton der Gegenseite aus (Empfangsspuren deaktiviert, auch nach Neuverhandlung) und selbst stumm; beim Aufheben Mikro-Zustand von vorher. - Stumm: schlug nur das Senden der Status-Info fehl, schaltete Pyramid den Knopf blind zurück, obwohl das Mikro aus war – danach zeigte der Knopf das Gegenteil. Jetzt wird der echte SDK-Zustand übernommen. - Mini-Anrufleiste erschien schon beim Klingeln (vor dem Annehmen). - Bildschirm-Auswähler: deutsch, Hinweis zur bekannten WebRTC-Grenze bei HDR unter Windows (blasses Bild; Fenster teilen oder Win+Alt+B). - Live-Anruftest drückt Stumm und Taub über die echten Knöpfe und prüft, was bei der Gegenseite ankommt. Co-Authored-By: Claude Opus 5.5 <[email protected]>
792 lines
27 KiB
Dart
792 lines
27 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;
|
||
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;
|
||
_isDeafened = false;
|
||
_isMicMuted = false;
|
||
_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;
|
||
|
||
if (_isDeafened) _applyDeafen(call);
|
||
|
||
// 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() => _setMicMuted(!_isMicMuted);
|
||
|
||
Future<void> _setMicMuted(bool muted) async {
|
||
final c = currentCall;
|
||
if (c == null) return;
|
||
_isMicMuted = muted;
|
||
notifyListeners();
|
||
try {
|
||
await _inSdkZone(() => c.setMicrophoneMuted(muted));
|
||
} catch (e) {
|
||
// Das SDK schaltet die Spur VOR dem Senden der Status-Info um. Schlägt
|
||
// nur das Senden fehl, ist das Mikro trotzdem (ent)stummt – früher
|
||
// wurde hier blind zurückgeschaltet, und der Knopf zeigte danach das
|
||
// Gegenteil des echten Zustands (Bernd 2026-10-08).
|
||
debugPrint('[VOIP] Stummschalten: $e');
|
||
}
|
||
_isMicMuted = c.isMicrophoneMuted;
|
||
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 {
|
||
// Wie Discord: taub = Gegenseite nicht mehr hören UND selbst stumm;
|
||
// beim Aufheben den Mikro-Zustand von vorher wiederherstellen. (Früher
|
||
// setzte der Knopf nur ein Häkchen ohne Wirkung.)
|
||
_isDeafened = !_isDeafened;
|
||
notifyListeners();
|
||
final c = currentCall;
|
||
if (c == null) return;
|
||
_applyDeafen(c);
|
||
if (_isDeafened) {
|
||
_micMutedBeforeDeafen = _isMicMuted;
|
||
if (!_isMicMuted) await _setMicMuted(true);
|
||
} else if (!_micMutedBeforeDeafen && _isMicMuted) {
|
||
await _setMicMuted(false);
|
||
}
|
||
}
|
||
|
||
bool _micMutedBeforeDeafen = false;
|
||
|
||
/// Ton der Gegenseite an/aus (deaktivierte Empfangsspur = stumm).
|
||
void _applyDeafen(CallSession call) {
|
||
for (final w in call.getRemoteStreams) {
|
||
for (final t in w.stream?.getAudioTracks() ?? const <MediaStreamTrack>[]) {
|
||
if (t.enabled == _isDeafened) t.enabled = !_isDeafened;
|
||
}
|
||
}
|
||
}
|
||
|
||
@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);
|
||
}
|