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:
Bernd Steckmeister
2026-10-08 11:05:46 +02:00
co-authored by Claude Opus 5.5
parent 5485b6d449
commit 8074928505
12 changed files with 793 additions and 97 deletions
@@ -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;
+229 -84
View File
@@ -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();
}
+8 -1
View File
@@ -87,7 +87,14 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
Widget build(BuildContext context) {
final pt = widget.pt;
final all = _sorted(widget.participants);
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ?? (all.isNotEmpty ? all.first : null);
// Ohne gewählten Hero nicht nach „spricht gerade“ wählen – sonst springt
// das große Bild bei jedem Sprechwechsel hin und her.
final stable = [...widget.participants]..sort((a, b) {
int r(_ParticipantData p) => p.isScreenShare ? 0 : (p.hasVideo ? 1 : 2);
return r(a).compareTo(r(b));
});
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ??
(stable.isNotEmpty ? stable.first : null);
if (widget.isConnecting) {
return Scaffold(
@@ -151,6 +151,7 @@ class VoiceChannelView extends ConsumerWidget {
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote',
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
isMuted: false,
isSpeaking: voip.isRemoteSpeaking,
hasVideo: hasRemoteVideo,
isScreenShare: isRemoteScreen,
avatarColor: _avatarColors[1],
@@ -165,6 +166,7 @@ class VoiceChannelView extends ConsumerWidget {
name: 'Du',
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
isMuted: voip.isMicMuted,
isSpeaking: voip.isLocalSpeaking,
hasVideo: hasLocalVideo,
isScreenShare: isLocalScreen,
avatarColor: _avatarColors[0],
@@ -103,7 +103,7 @@ extension _BubbleBuilder on _MessageGroupState {
rows.add(_ReadMarker(pt: pt));
}
if (widget.fullyReadId == event.eventId && !isLast) {
rows.add(_InlineUnreadDivider(pt: pt));
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
}
}
@@ -32,6 +32,9 @@ class MessageGroup extends StatefulWidget {
/// When non-null and pointing to a non-last event in this group, an inline
/// "Neue Nachrichten" divider is rendered after that event's row.
final String? fullyReadId;
/// Ausblenden des Inline-Trenners + Schlüssel für den Sprung zu ihm.
final bool unreadFading;
final GlobalKey? unreadDividerKey;
/// Jump-to-message (from search): the row matching [jumpEventId] gets
/// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted.
final String? jumpEventId;
@@ -55,6 +58,8 @@ class MessageGroup extends StatefulWidget {
this.showStatusIndicator = false,
this.readEventIds = const {},
this.fullyReadId,
this.unreadFading = false,
this.unreadDividerKey,
this.jumpEventId,
this.jumpKey,
this.highlightEventId,
@@ -155,7 +160,7 @@ class _MessageGroupState extends State<MessageGroup> {
// Inline unread divider after the first event when it's the fullyRead
// marker and there are more events in this group.
if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) {
rows.add(_InlineUnreadDivider(pt: pt));
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
}
// ── Continuation event rows (siblings, same indentation) ──
@@ -174,7 +179,7 @@ class _MessageGroupState extends State<MessageGroup> {
if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt));
// Inline divider after this event if it's the fullyRead and not the last.
if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) {
rows.add(_InlineUnreadDivider(pt: pt));
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
}
}
+11 -2
View File
@@ -93,11 +93,19 @@ class _InitialBox extends StatelessWidget {
// fullyRead event is in the middle of the group (same sender, <5 min window).
class _InlineUnreadDivider extends StatelessWidget {
final PyramidTheme pt;
const _InlineUnreadDivider({required this.pt});
final bool fading;
const _InlineUnreadDivider({super.key, required this.pt, this.fading = false});
@override
Widget build(BuildContext context) {
return Padding(
// Blendet aus wie der Gruppen-Trenner (vorher blieb dieser „Neue
// Nachrichten“-Trenner mitten in einer Gruppe bis zum erneuten Öffnen
// stehen – Bernd 2026-10-08).
return AnimatedOpacity(
opacity: fading ? 0.0 : 1.0,
duration: const Duration(milliseconds: 700),
curve: Curves.easeOut,
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
child: Row(
children: [
@@ -112,6 +120,7 @@ class _InlineUnreadDivider extends StatelessWidget {
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
],
),
),
);
}
}
@@ -216,10 +216,11 @@ class _MessageList extends StatelessWidget {
// Pass fullyReadId to the group only when the event is mid-group
// (not the last event) so MessageGroup can render an inline divider.
// groupContainsFullyRead already guarantees fullyReadId != null.
final passedFullyReadId =
(groupContainsFullyRead && group.last.eventId != fullyReadId)
? fullyReadId
: null;
final passedFullyReadId = (hasUnread &&
groupContainsFullyRead &&
group.last.eventId != fullyReadId)
? fullyReadId
: null;
itemFullyReadIds.add(passedFullyReadId);
if (isLastOfDay) {
@@ -292,6 +293,9 @@ class _MessageList extends StatelessWidget {
showStatusIndicator: showStatus,
readEventIds: readEventIds,
fullyReadId: itemFullyReadIds[i],
unreadFading: unreadFading,
unreadDividerKey:
itemFullyReadIds[i] != null ? unreadDividerKey : null,
jumpEventId: jumpEventId,
jumpKey: jumpKey,
highlightEventId: highlightEventId,
+4 -1
View File
@@ -305,7 +305,10 @@ class _ActiveCallBar extends ConsumerWidget {
final lk = ref.watch(voiceChannelProvider);
final voipCall = voip.currentCall;
final String? name;
if (voipCall != null && voipCall.state != CallState.kEnded) {
// Klingeln zählt nicht als „läuft“ – dafür gibt es das Annehmen-Fenster.
if (voipCall != null &&
voipCall.state != CallState.kEnded &&
voipCall.state != CallState.kRinging) {
name = voipCall.room.getLocalizedDisplayname();
} else if (lk.isActive || lk.isConnecting) {
name = lk.displayName;