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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5485b6d449
commit
8074928505
@@ -49,6 +49,11 @@ abstract class CallSignalingService extends ChangeNotifier {
|
||||
|
||||
Future<void> changeQuality(String key);
|
||||
|
||||
/// Spricht gerade jemand? (Pegel aus den WebRTC-Statistiken, für den
|
||||
/// grünen Sprech-Ring.)
|
||||
bool get isLocalSpeaking => false;
|
||||
bool get isRemoteSpeaking => false;
|
||||
|
||||
/// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
|
||||
/// Auf dem PC ohne Bedeutung.
|
||||
bool get isSpeakerOn => true;
|
||||
|
||||
@@ -20,6 +20,7 @@ class PyramidVoipManager extends CallSignalingService
|
||||
|
||||
final Client client;
|
||||
late final VoIP voip;
|
||||
late final Zone _sdkZone;
|
||||
@override
|
||||
CallSession? currentCall;
|
||||
|
||||
@@ -81,7 +82,21 @@ class PyramidVoipManager extends CallSignalingService
|
||||
|
||||
PyramidVoipManager(this.client) {
|
||||
_instance = this;
|
||||
voip = VoIP(client, 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 {};
|
||||
@@ -89,6 +104,29 @@ class PyramidVoipManager extends CallSignalingService
|
||||
_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 {
|
||||
@@ -137,10 +175,82 @@ class PyramidVoipManager extends CallSignalingService
|
||||
_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();
|
||||
@@ -151,11 +261,15 @@ class PyramidVoipManager extends CallSignalingService
|
||||
call.setLocalVideoMuted(false);
|
||||
}
|
||||
_applySpeaker();
|
||||
_startLevelPolling();
|
||||
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
|
||||
CallForeground.callStarted('voip',
|
||||
title: call.room.getLocalizedDisplayname());
|
||||
}
|
||||
if (state == CallState.kEnded) CallForeground.callEnded('voip');
|
||||
if (state == CallState.kEnded) {
|
||||
_stopLevelPolling();
|
||||
CallForeground.callEnded('voip');
|
||||
}
|
||||
_updateRenderers();
|
||||
});
|
||||
// Das SDK setzt bei jedem neuen eigenen Medienstrom die Hörmuschel –
|
||||
@@ -167,7 +281,15 @@ class PyramidVoipManager extends CallSignalingService
|
||||
_renderRefreshTimer?.cancel();
|
||||
_renderRefreshTimer = Timer.periodic(
|
||||
const Duration(milliseconds: 1000), // Faster polling to catch feeds
|
||||
(_) => _updateRenderers(),
|
||||
(_) {
|
||||
_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();
|
||||
@@ -184,99 +306,66 @@ class PyramidVoipManager extends CallSignalingService
|
||||
}
|
||||
|
||||
Future<void> _updateRenderers() async {
|
||||
if (currentCall == null || _isUpdating) return;
|
||||
final call = currentCall;
|
||||
if (call == 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;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
_isScreensharing = localScreenFound;
|
||||
_isRemoteScreensharing = remoteScreenFound;
|
||||
final remoteScreen = video(call.remoteScreenSharingStream);
|
||||
final localScreen = video(call.localScreenSharingStream);
|
||||
final remoteUser = call.remoteUserMediaStream?.stream;
|
||||
|
||||
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 (_) {}
|
||||
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 Remote-Stream anwenden —
|
||||
// Ausgabelautstärke (Settings-Slider) auf den Ton der Gegenseite –
|
||||
// nur einmal pro Stream, _updateRenderers läuft auch per Timer.
|
||||
if (remote != null && remote.id != _volumeAppliedStreamId) {
|
||||
_volumeAppliedStreamId = remote.id;
|
||||
_remoteAudioStream = remote;
|
||||
if (remoteUser != null && remoteUser.id != _volumeAppliedStreamId) {
|
||||
_volumeAppliedStreamId = remoteUser.id;
|
||||
_remoteAudioStream = remoteUser;
|
||||
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;
|
||||
// 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 (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');
|
||||
@@ -285,11 +374,64 @@ class PyramidVoipManager extends CallSignalingService
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
@@ -354,7 +496,7 @@ class PyramidVoipManager extends CallSignalingService
|
||||
@override
|
||||
Future<void> safeAction(Future<dynamic> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
await _inSdkZone(action);
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
} catch (e) {
|
||||
debugPrint('[VOIP] Action error: $e');
|
||||
@@ -369,7 +511,8 @@ class PyramidVoipManager extends CallSignalingService
|
||||
_isMicMuted = !_isMicMuted;
|
||||
notifyListeners();
|
||||
try {
|
||||
await currentCall!.setMicrophoneMuted(_isMicMuted);
|
||||
final c = currentCall!;
|
||||
await _inSdkZone(() => c.setMicrophoneMuted(_isMicMuted));
|
||||
} catch (e) {
|
||||
_isMicMuted = !_isMicMuted;
|
||||
notifyListeners();
|
||||
@@ -382,7 +525,8 @@ class PyramidVoipManager extends CallSignalingService
|
||||
_isCameraMuted = !_isCameraMuted;
|
||||
notifyListeners();
|
||||
try {
|
||||
await currentCall!.setLocalVideoMuted(_isCameraMuted);
|
||||
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();
|
||||
@@ -445,14 +589,15 @@ class PyramidVoipManager extends CallSignalingService
|
||||
|
||||
_speakerOn = true;
|
||||
debugPrint('[VOIP] Starting call in room $roomId (video=$video)');
|
||||
final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice);
|
||||
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 call.setLocalVideoMuted(false);
|
||||
await _inSdkZone(() => call.setLocalVideoMuted(false));
|
||||
await _updateRenderers();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user