From f1af7f9f1c7f70396126ab6c9e8c1d9fba90e890 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 16 Sep 2026 15:44:13 +0200 Subject: [PATCH 01/13] fix(llc,ui): stop re-emitting participant state for events that change nothing Livestreams with many joining participants produce a high rate of SFU events. Every handler rebuilt the whole participant list and pushed a new CallState, so listeners woke on every audio level tick whether or not anything they render had changed. - Guard the SFU participant handlers: audio level, connection quality, dominant speaker, inbound video state and participant updated now keep the existing participant instances and skip the state write when the event leaves everything unchanged. Matching is done through a map built once per event instead of a scan per participant. - Add `throttleByCollectionSize`, a list-stream throttle whose interval grows with the list size, and apply it to the participant subscriptions in `StreamCallParticipants` and the Android PiP overlay. The tiers mirror stream-video-swift's `CollectionDelayedUpdateObserver` and stream-video-android's `participantsUpdateConfig`. - Sort participants through a precomputed order map rather than an `indexOf` inside the comparator, and skip `setState` when the resulting list is unchanged. - Stop `copyWithUpdatedAudioLevels` mutating the audio level history it shares with the previous snapshot. No benchmark was run, so this carries no measured improvement over main. FLU-793 Co-Authored-By: Claude Opus 5 --- packages/stream_video/CHANGELOG.md | 11 + .../call/state/mixins/state_sfu_mixin.dart | 195 ++++++++++----- .../src/models/call_participant_state.dart | 9 +- .../lib/src/utils/adaptive_throttle.dart | 37 +++ packages/stream_video/lib/stream_video.dart | 1 + .../src/call/state/state_sfu_mixin_test.dart | 226 ++++++++++++++++++ .../src/utils/adaptive_throttle_test.dart | 86 +++++++ packages/stream_video_flutter/CHANGELOG.md | 7 + .../call_participants/call_participants.dart | 2 + .../call_participants_sorting_mixin.dart | 72 ++++-- .../android_pip_overlay.dart | 1 + 11 files changed, 558 insertions(+), 89 deletions(-) create mode 100644 packages/stream_video/lib/src/utils/adaptive_throttle.dart create mode 100644 packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart create mode 100644 packages/stream_video/test/src/utils/adaptive_throttle_test.dart diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index d4b4c4a66..83e1b6f9e 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -1,3 +1,14 @@ +## Upcoming + +### 🔄 Changed + +- SFU participant events no longer emit a new call state when they leave every participant unchanged. +- Added `throttleByCollectionSize`, which rate-limits a list stream at an interval that grows with the list size. + +### 🐞 Fixed + +- Fixed `CallParticipantState.copyWithUpdatedAudioLevels` mutating the audio level history it shares with the previous state. + ## 1.6.0 ### ✅ Added diff --git a/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart b/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart index 12a51c09d..1dc39f5dc 100644 --- a/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart +++ b/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart @@ -4,12 +4,17 @@ import 'package:state_notifier/state_notifier.dart'; import '../../../../stream_video.dart'; import '../../../models/call_participant_pin.dart'; import '../../../sfu/data/events/sfu_events.dart'; +import '../../../sfu/data/models/sfu_inbound_video_state.dart'; import '../../../sfu/data/models/sfu_pin.dart'; import '../../../sfu/sfu_extensions.dart'; import 'state_pending_tracks_mixin.dart'; final _logger = taggedLogger(tag: 'SV:CallState:Sfu'); +/// Identifies a participant the way the SFU does: a user can be in the same +/// call from several devices, so the session is part of the identity. +String _participantKey(String userId, String sessionId) => '$userId:$sessionId'; + mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { void sfuParticipantLeft( SfuParticipantLeftEvent event, @@ -128,22 +133,39 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { void sfuUpdateAudioLevelChanged( SfuAudioLevelChangedEvent event, ) { - state = state.copyWith( - callParticipants: state.callParticipants.map((participant) { - final levelInfo = event.audioLevels.firstWhereOrNull((level) { - return level.userId == participant.userId && - level.sessionId == participant.sessionId; - }); - if (levelInfo != null) { - return participant.copyWithUpdatedAudioLevels( - audioLevel: levelInfo.level, - isSpeaking: levelInfo.isSpeaking, - ); - } else { - return participant; - } - }).toList(), - ); + if (event.audioLevels.isEmpty) return; + + final levelsByParticipant = { + for (final level in event.audioLevels) + _participantKey(level.userId, level.sessionId): level, + }; + + var changed = false; + final participants = state.callParticipants.map((participant) { + final levelInfo = + levelsByParticipant[_participantKey( + participant.userId, + participant.sessionId, + )]; + + // A silent participant who was already silent carries no new information, + // so keep the existing instance and leave the list identical. + if (levelInfo == null || + (!levelInfo.isSpeaking && + participant.isSpeaking == levelInfo.isSpeaking)) { + return participant; + } + + changed = true; + return participant.copyWithUpdatedAudioLevels( + audioLevel: levelInfo.level, + isSpeaking: levelInfo.isSpeaking, + ); + }).toList(); + + if (!changed) return; + + state = state.copyWith(callParticipants: participants); } void sfuDominantSpeakerChanged( @@ -153,6 +175,16 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { () => '[sfuDominantSpeakerChanged] ${state.sessionId}; event: $event', ); + final current = state.callParticipants.firstWhereOrNull( + (participant) => participant.isDominantSpeaker, + ); + + if (current != null && + current.userId == event.userId && + current.sessionId == event.sessionId) { + return; + } + state = state.copyWith( callParticipants: state.callParticipants.map((participant) { // Mark the new dominant speaker @@ -181,13 +213,16 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { void sfuPinsUpdated( List pins, ) { + final pinnedKeys = { + for (final pin in pins) _participantKey(pin.userId, pin.sessionId), + }; + state = state.copyWith( callParticipants: state.callParticipants.map((participant) { - final pin = pins.firstWhereOrNull((it) { - return it.userId == participant.userId && - it.sessionId == participant.sessionId; - }); - if (pin != null) { + final isPinned = pinnedKeys.contains( + _participantKey(participant.userId, participant.sessionId), + ); + if (isPinned) { return participant.copyWithPin( participantPin: CallParticipantPin( isLocalPin: false, @@ -208,23 +243,34 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { void sfuConnectionQualityChanged( SfuConnectionQualityChangedEvent event, ) { - state = state.copyWith( - callParticipants: state.callParticipants.map((participant) { - final update = event.connectionQualityUpdates.firstWhereOrNull((it) { - return it.userId == participant.userId && - it.sessionId == participant.sessionId; - }); - if (update != null) { - return participant.copyWith( - connectionQuality: update.connectionQuality.mergeWithPrevious( - participant.connectionQuality, - ), - ); - } else { - return participant; - } - }).toList(), - ); + if (event.connectionQualityUpdates.isEmpty) return; + + final updatesByParticipant = { + for (final update in event.connectionQualityUpdates) + _participantKey(update.userId, update.sessionId): update, + }; + + var changed = false; + final participants = state.callParticipants.map((participant) { + final update = + updatesByParticipant[_participantKey( + participant.userId, + participant.sessionId, + )]; + if (update == null) return participant; + + final quality = update.connectionQuality.mergeWithPrevious( + participant.connectionQuality, + ); + if (quality == participant.connectionQuality) return participant; + + changed = true; + return participant.copyWith(connectionQuality: quality); + }).toList(); + + if (!changed) return; + + state = state.copyWith(callParticipants: participants); } void sfuParticipantJoined( @@ -282,6 +328,13 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { ); final participant = event.participant; + final isKnown = state.callParticipants.any( + (it) => + it.userId == participant.userId && + it.sessionId == participant.sessionId, + ); + if (!isKnown) return; + final participants = state.callParticipants.map((it) { if (it.userId == participant.userId && it.sessionId == participant.sessionId) { @@ -317,30 +370,54 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { () => '[sfuInboundStateNotification] ${state.sessionId}; event: $event', ); - state = state.copyWith( - callParticipants: state.callParticipants.map((participant) { - final inboundStates = event.inboundVideoStates.where((it) { - return it.userId == participant.userId && - it.sessionId == participant.sessionId; - }).toList(); + if (event.inboundVideoStates.isEmpty) return; - if (inboundStates.isEmpty) { - return participant; - } + final statesByParticipant = >{}; + for (final inboundState in event.inboundVideoStates) { + statesByParticipant + .putIfAbsent( + _participantKey(inboundState.userId, inboundState.sessionId), + () => [], + ) + .add(inboundState); + } + + var changed = false; + final participants = state.callParticipants.map((participant) { + final inboundStates = + statesByParticipant[_participantKey( + participant.userId, + participant.sessionId, + )]; - final pausedTracks = {...participant.pausedTracks}; - for (final inboundState in inboundStates) { - if (inboundState.paused) { - pausedTracks.add(inboundState.trackType); - } else { - pausedTracks.remove(inboundState.trackType); - } + if (inboundStates == null) { + return participant; + } + + final pausedTracks = {...participant.pausedTracks}; + for (final inboundState in inboundStates) { + if (inboundState.paused) { + pausedTracks.add(inboundState.trackType); + } else { + pausedTracks.remove(inboundState.trackType); } + } - return participant.copyWith( - pausedTracks: pausedTracks, - ); - }).toList(), - ); + if (const SetEquality().equals( + pausedTracks, + participant.pausedTracks, + )) { + return participant; + } + + changed = true; + return participant.copyWith( + pausedTracks: pausedTracks, + ); + }).toList(); + + if (!changed) return; + + state = state.copyWith(callParticipants: participants); } } diff --git a/packages/stream_video/lib/src/models/call_participant_state.dart b/packages/stream_video/lib/src/models/call_participant_state.dart index 98b590156..3e3fe87bf 100644 --- a/packages/stream_video/lib/src/models/call_participant_state.dart +++ b/packages/stream_video/lib/src/models/call_participant_state.dart @@ -165,15 +165,14 @@ class CallParticipantState extends Equatable required double audioLevel, bool? isSpeaking, }) { - final levels = audioLevels; - levels.add(audioLevel); - while (levels.length > 10) { - levels.removeAt(0); + final levels = [...audioLevels, audioLevel]; + if (levels.length > 10) { + levels.removeRange(0, levels.length - 10); } return copyWith( audioLevel: audioLevel, - audioLevels: audioLevels, + audioLevels: levels, isSpeaking: isSpeaking, ); } diff --git a/packages/stream_video/lib/src/utils/adaptive_throttle.dart b/packages/stream_video/lib/src/utils/adaptive_throttle.dart new file mode 100644 index 000000000..7dc83f4f3 --- /dev/null +++ b/packages/stream_video/lib/src/utils/adaptive_throttle.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:rxdart/rxdart.dart'; + +/// Emission intervals for [AdaptiveCollectionThrottleX], keyed on how large the +/// collection currently is. +/// +/// The tiers mirror the ones used by the iOS and Android SDKs, so a livestream +/// updates at a comparable rate on every platform. +Duration defaultCollectionThrottleInterval(int size) { + if (size < 16) return const Duration(milliseconds: 16); + if (size < 50) return const Duration(milliseconds: 250); + if (size < 100) return const Duration(milliseconds: 500); + return const Duration(seconds: 1); +} + +extension AdaptiveCollectionThrottleX on Stream> { + /// Rate-limits this stream, emitting at most once per interval, where the + /// interval grows with the size of the most recent list. + /// + /// The first value of each window is emitted immediately and the last one is + /// emitted when the window closes, so a change is never dropped — only + /// collapsed with the ones around it. A window that saw a single value closes + /// without repeating it. + /// + /// Use it for participant lists feeding the UI. Don't use it for values a + /// caller acts on rather than renders, such as the call status, where a delay + /// of up to a second would be visible as lag. + Stream> throttleByCollectionSize({ + Duration Function(int size) interval = defaultCollectionThrottleInterval, + }) { + return throttle( + (value) => TimerStream(null, interval(value.length)), + trailing: true, + ).distinct(identical); + } +} diff --git a/packages/stream_video/lib/stream_video.dart b/packages/stream_video/lib/stream_video.dart index f51f3cbea..9e6ff2dd3 100644 --- a/packages/stream_video/lib/stream_video.dart +++ b/packages/stream_video/lib/stream_video.dart @@ -57,6 +57,7 @@ export 'src/state_emitter.dart' show MutableStateEmitter, StateEmitter; export 'src/stream_video.dart'; export 'src/token/token.dart'; export 'src/types/other.dart'; +export 'src/utils/adaptive_throttle.dart'; export 'src/utils/none.dart'; export 'src/utils/result.dart'; export 'src/utils/string.dart'; diff --git a/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart b/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart new file mode 100644 index 000000000..d067eb84b --- /dev/null +++ b/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart @@ -0,0 +1,226 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video/src/call/state/call_state_notifier.dart'; +import 'package:stream_video/src/sfu/data/events/sfu_events.dart'; +import 'package:stream_video/src/sfu/data/models/sfu_audio_level.dart'; +import 'package:stream_video/src/sfu/data/models/sfu_connection_info.dart'; +import 'package:stream_video/stream_video.dart'; + +CallParticipantState _participant({ + required String userId, + bool isSpeaking = false, + bool isDominantSpeaker = false, + SfuConnectionQuality connectionQuality = SfuConnectionQuality.unspecified, +}) { + return CallParticipantState( + userId: userId, + roles: const [], + name: userId, + custom: const {}, + sessionId: '$userId-session', + trackIdPrefix: '$userId-prefix', + isSpeaking: isSpeaking, + isDominantSpeaker: isDominantSpeaker, + connectionQuality: connectionQuality, + ); +} + +CallStateNotifier _notifier(List participants) { + final callState = CallState( + callCid: StreamCallCid.from( + type: StreamCallType.defaultType(), + id: 'id', + ), + currentUserId: 'userId', + preferences: DefaultCallPreferences(), + ).copyWith(callParticipants: participants); + + return CallStateNotifier(callState); +} + +void main() { + group('sfuUpdateAudioLevelChanged', () { + test('does not emit when every participant stays silent', () async { + final notifier = _notifier([_participant(userId: 'alice')]); + final before = notifier.callState.callParticipants; + + notifier.sfuUpdateAudioLevelChanged( + const SfuAudioLevelChangedEvent( + audioLevels: [ + SfuAudioLevel( + userId: 'alice', + sessionId: 'alice-session', + level: 0.2, + isSpeaking: false, + ), + ], + ), + ); + + expect(identical(notifier.callState.callParticipants, before), isTrue); + }); + + test('emits when a participant starts speaking', () async { + final notifier = _notifier([_participant(userId: 'alice')]); + + notifier.sfuUpdateAudioLevelChanged( + const SfuAudioLevelChangedEvent( + audioLevels: [ + SfuAudioLevel( + userId: 'alice', + sessionId: 'alice-session', + level: 0.8, + isSpeaking: true, + ), + ], + ), + ); + + final alice = notifier.callState.callParticipants.single; + expect(alice.isSpeaking, isTrue); + expect(alice.audioLevel, 0.8); + }); + + test('keeps untouched participants identical', () async { + final notifier = _notifier([ + _participant(userId: 'alice'), + _participant(userId: 'bob'), + ]); + final bobBefore = notifier.callState.callParticipants[1]; + + notifier.sfuUpdateAudioLevelChanged( + const SfuAudioLevelChangedEvent( + audioLevels: [ + SfuAudioLevel( + userId: 'alice', + sessionId: 'alice-session', + level: 0.8, + isSpeaking: true, + ), + ], + ), + ); + + expect( + identical(notifier.callState.callParticipants[1], bobBefore), + isTrue, + ); + }); + + test( + 'does not share the audio level history with the previous state', + () async { + final notifier = _notifier([_participant(userId: 'alice')]); + final before = notifier.callState.callParticipants.single; + final levelsBefore = [...before.audioLevels]; + + notifier.sfuUpdateAudioLevelChanged( + const SfuAudioLevelChangedEvent( + audioLevels: [ + SfuAudioLevel( + userId: 'alice', + sessionId: 'alice-session', + level: 0.8, + isSpeaking: true, + ), + ], + ), + ); + + expect(before.audioLevels, levelsBefore); + expect( + notifier.callState.callParticipants.single.audioLevels, + [...levelsBefore, 0.8], + ); + }, + ); + }); + + group('sfuConnectionQualityChanged', () { + test('does not emit when the quality is unchanged', () async { + final notifier = _notifier([ + _participant( + userId: 'alice', + connectionQuality: SfuConnectionQuality.good, + ), + ]); + final before = notifier.callState.callParticipants; + + notifier.sfuConnectionQualityChanged( + const SfuConnectionQualityChangedEvent( + connectionQualityUpdates: [ + SfuConnectionQualityInfo( + userId: 'alice', + sessionId: 'alice-session', + connectionQuality: SfuConnectionQuality.good, + ), + ], + ), + ); + + expect(identical(notifier.callState.callParticipants, before), isTrue); + }); + + test('emits when the quality changes', () async { + final notifier = _notifier([ + _participant( + userId: 'alice', + connectionQuality: SfuConnectionQuality.good, + ), + ]); + + notifier.sfuConnectionQualityChanged( + const SfuConnectionQualityChangedEvent( + connectionQualityUpdates: [ + SfuConnectionQualityInfo( + userId: 'alice', + sessionId: 'alice-session', + connectionQuality: SfuConnectionQuality.poor, + ), + ], + ), + ); + + expect( + notifier.callState.callParticipants.single.connectionQuality, + SfuConnectionQuality.poor, + ); + }); + }); + + group('sfuDominantSpeakerChanged', () { + test('does not emit when the dominant speaker is unchanged', () async { + final notifier = _notifier([ + _participant(userId: 'alice', isDominantSpeaker: true), + _participant(userId: 'bob'), + ]); + final before = notifier.callState.callParticipants; + + notifier.sfuDominantSpeakerChanged( + const SfuDominantSpeakerChangedEvent( + userId: 'alice', + sessionId: 'alice-session', + ), + ); + + expect(identical(notifier.callState.callParticipants, before), isTrue); + }); + + test('moves the flag when the dominant speaker changes', () async { + final notifier = _notifier([ + _participant(userId: 'alice', isDominantSpeaker: true), + _participant(userId: 'bob'), + ]); + + notifier.sfuDominantSpeakerChanged( + const SfuDominantSpeakerChangedEvent( + userId: 'bob', + sessionId: 'bob-session', + ), + ); + + final participants = notifier.callState.callParticipants; + expect(participants[0].isDominantSpeaker, isFalse); + expect(participants[1].isDominantSpeaker, isTrue); + }); + }); +} diff --git a/packages/stream_video/test/src/utils/adaptive_throttle_test.dart b/packages/stream_video/test/src/utils/adaptive_throttle_test.dart new file mode 100644 index 000000000..0c7f24b96 --- /dev/null +++ b/packages/stream_video/test/src/utils/adaptive_throttle_test.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video/stream_video.dart'; + +void main() { + test('interval grows with the size of the collection', () { + expect( + defaultCollectionThrottleInterval(1), + const Duration(milliseconds: 16), + ); + expect( + defaultCollectionThrottleInterval(20), + const Duration(milliseconds: 250), + ); + expect( + defaultCollectionThrottleInterval(60), + const Duration(milliseconds: 500), + ); + expect( + defaultCollectionThrottleInterval(500), + const Duration(seconds: 1), + ); + }); + + test('emits the first and the last value of a burst', () async { + final controller = StreamController>(); + final received = >[]; + + final subscription = controller.stream + .throttleByCollectionSize( + interval: (_) => const Duration(milliseconds: 50), + ) + .listen(received.add); + + controller + ..add([1]) + ..add([2]) + ..add([3]); + + await Future.delayed(const Duration(milliseconds: 120)); + + expect(received, [ + [1], + [3], + ]); + + await subscription.cancel(); + await controller.close(); + }); + + test('a larger collection is throttled for longer', () async { + final controller = StreamController>(); + final received = >[]; + + final subscription = controller.stream + .throttleByCollectionSize( + interval: (size) => Duration(milliseconds: size < 3 ? 10 : 200), + ) + .listen(received.add); + + // A small list opens a short window, so the next value is let through. + controller.add([1]); + await Future.delayed(const Duration(milliseconds: 30)); + controller.add([1, 2]); + await Future.delayed(const Duration(milliseconds: 30)); + expect(received, [ + [1], + [1, 2], + ]); + + // A large list opens a long window that holds back the values behind it. + controller + ..add([1, 2, 3]) + ..add([1, 2, 3, 4]) + ..add([1, 2, 3, 4, 5]); + await Future.delayed(const Duration(milliseconds: 60)); + expect(received.length, 3); + + await Future.delayed(const Duration(milliseconds: 250)); + expect(received.last, [1, 2, 3, 4, 5]); + + await subscription.cancel(); + await controller.close(); + }); +} diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 80ddd3944..b0507b5ff 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -1,3 +1,10 @@ +## Upcoming + +### 🔄 Changed + +- Participant list subscriptions are now throttled at an interval that grows with the participant count. +- `StreamCallParticipants` and `StreamLivestreamHosts` no longer rebuild when an update leaves the rendered participants unchanged. + ## 1.6.0 ### ✅ Added diff --git a/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart b/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart index 628fc464c..cd0d14fc7 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart @@ -129,6 +129,7 @@ class _StreamCallParticipantsState extends State if (widget.participants == null) { _participantsSubscription = widget.call .partialState((state) => state.callParticipants) + .throttleByCollectionSize() .listen(recalculateParticipants); } } @@ -156,6 +157,7 @@ class _StreamCallParticipantsState extends State _participantsSubscription?.cancel(); _participantsSubscription = widget.call .partialState((state) => state.callParticipants) + .throttleByCollectionSize() .listen(recalculateParticipants); recalculateParticipants(widget.call.state.value.callParticipants); diff --git a/packages/stream_video_flutter/lib/src/call_participants/call_participants_sorting_mixin.dart b/packages/stream_video_flutter/lib/src/call_participants/call_participants_sorting_mixin.dart index 1fa113002..2d632a494 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/call_participants_sorting_mixin.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/call_participants_sorting_mixin.dart @@ -28,31 +28,42 @@ mixin CallParticipantsSortingMixin on State { /// Call this method whenever the participant list changes, typically from /// a stream subscription or in [didUpdateWidget]. void recalculateParticipants(List newParticipants) { - final participants = [ - ...newParticipants, - ].where(participantFilter ?? (_) => true).toList(); - - for (final participant in participants) { - final index = _sortedParticipantKeys.indexOf( - participant.uniqueParticipantKey, + final filter = participantFilter; + final participants = filter == null + ? newParticipants + : newParticipants.where(filter).toList(); + + // Position of each key in the previous order, so the sort below reads a + // participant's previous slot in constant time. + final previousOrder = { + for (var index = 0; index < _sortedParticipantKeys.length; index++) + _sortedParticipantKeys[index]: index, + }; + + // Participants that weren't in the previous order are appended, in the + // order they arrived. + var nextOrder = previousOrder.length; + final entries = [ + for (final participant in participants) + ( + key: participant.uniqueParticipantKey, + order: previousOrder[participant.uniqueParticipantKey] ?? nextOrder++, + participant: participant, + ), + ]..sort((a, b) => a.order.compareTo(b.order)); + + final sort = participantSort; + if (sort != null) { + mergeSort( + entries, + compare: (a, b) => sort(a.participant, b.participant), ); - if (index == -1) { - _sortedParticipantKeys.add(participant.uniqueParticipantKey); - } } - // First apply previous sorting on new participants list - participants.sort( - (a, b) => _sortedParticipantKeys - .indexOf(a.uniqueParticipantKey) - .compareTo(_sortedParticipantKeys.indexOf(b.uniqueParticipantKey)), - ); - - if (participantSort != null) { - mergeSort(participants, compare: participantSort); - } + final sortedKeys = [for (final entry in entries) entry.key]; + final sortedParticipants = [for (final entry in entries) entry.participant]; - final screenShareParticipant = participants.firstWhereOrNull( + final screenShareParticipant = sortedParticipants.firstWhereOrNull( (it) { final screenShareTrack = it.screenShareTrack; final isScreenShareEnabled = it.isScreenShareEnabled; @@ -63,13 +74,24 @@ mixin CallParticipantsSortingMixin on State { }, ); - _sortedParticipantKeys = participants - .map((e) => e.uniqueParticipantKey) - .toList(); + _sortedParticipantKeys = sortedKeys; + + // The state layer hands back the same participant instance when an event + // leaves that participant untouched, so identity is enough to tell whether + // anything on screen would actually differ. + final unchanged = + identical(screenShareParticipant, _screenShareParticipant) && + sortedParticipants.length == _participants.length && + sortedParticipants.foldIndexed( + true, + (index, acc, it) => acc && identical(it, _participants[index]), + ); + + if (unchanged) return; if (mounted) { setState(() { - _participants = participants.toList(); + _participants = sortedParticipants; _screenShareParticipant = screenShareParticipant; }); } diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart index 3589a6374..f189002c4 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart @@ -57,6 +57,7 @@ class _AndroidPipOverlayState extends State _participantsSubscription = widget.call .partialState((state) => state.callParticipants) + .throttleByCollectionSize() .listen(recalculateParticipants); } From 4b6b304e65374d08481ec7a2a8daa42fa87613a4 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 16 Sep 2026 15:58:31 +0200 Subject: [PATCH 02/13] refactor(llc): move the participant throttle into Call and make it configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both native SDKs keep this policy below the public participant list: Swift's CollectionDelayedUpdateObserver is wired up in CallController and Android's TaskSchedulerWithDebounce lives in core CallState. Neither UI module throttles anything. Add `Call.participantsStream` and subscribe the participant widgets to it, so the UI layer carries no rate-limiting of its own and every listener shares one throttle. `throttleByCollectionSize` is no longer exported; it is an implementation detail of that stream, matching CollectionDelayedUpdateObserver not being public in Swift. Add `CallPreferences.participantsThrottleInterval` so an integrator can pick their own interval, or pass null to emit every update. It takes the participant count and returns a duration, which is the shape the default tiers already had. Neither native SDK exposes this. The throttle is trailing-only. rxdart's `eventAfterLastWindow` closes a window before the next value reopens it, so with a leading emission a continuous source gets two values per window — the trailing one, then the next value as the new window's leading one — and the tiers would mean half what they say. Measured at 9 emissions per 500ms against a 100ms window. Combine's throttle(latest:) emits once per interval, and this now matches. The cost is that the first emission to a listener waits one window, which is why both widgets seed from `CallState.callParticipants` first. `CallState.callParticipants` stays immediate. Five call sites read it synchronously — `_getTrackForParticipant` starts a track straight off a TrackPublished event, the ringing flow checks who is left, and dynascale, the rtc manager and the participant mapper all do lookups. Swift has the same split: `WebRTCStateAdapter.participants` is immediate and only the projection into `Call.state.participantsMap` is throttled. Also simplify the audio level change guard, where the `==` against `levelInfo.isSpeaking` only ever compared against false. Co-Authored-By: Claude Opus 5 --- packages/stream_video/CHANGELOG.md | 3 +- packages/stream_video/lib/src/call/call.dart | 29 +++++++ .../call/state/mixins/state_sfu_mixin.dart | 3 +- .../lib/src/models/call_preferences.dart | 22 +++++ .../stream_video/lib/src/models/models.dart | 1 + .../lib/src/models/participants_throttle.dart | 16 ++++ .../lib/src/utils/adaptive_throttle.dart | 39 ++++----- packages/stream_video/lib/stream_video.dart | 1 - .../src/utils/adaptive_throttle_test.dart | 82 +++++++++++++++---- packages/stream_video_flutter/CHANGELOG.md | 2 +- .../call_participants/call_participants.dart | 14 ++-- .../android_pip_overlay.dart | 7 +- 12 files changed, 165 insertions(+), 54 deletions(-) create mode 100644 packages/stream_video/lib/src/models/participants_throttle.dart diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index 83e1b6f9e..713236ef0 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -3,7 +3,8 @@ ### 🔄 Changed - SFU participant events no longer emit a new call state when they leave every participant unchanged. -- Added `throttleByCollectionSize`, which rate-limits a list stream at an interval that grows with the list size. +- Added `Call.participantsStream`, which emits the participant list at an interval that grows with the participant count. `CallState.callParticipants` is unchanged. +- Added `CallPreferences.participantsThrottleInterval` to override that interval, or set it to `null` to emit every update. ### 🐞 Fixed diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index 9e440c185..8cec7c087 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -37,6 +37,7 @@ import '../shared_emitter.dart'; import '../state_emitter.dart'; import '../stream_video.dart'; import '../telemetry/client_event_types.dart'; +import '../utils/adaptive_throttle.dart'; import '../utils/cancelable_operation.dart'; import '../utils/cancelables.dart'; import '../utils/extensions.dart'; @@ -432,6 +433,34 @@ class Call { return _stateManager.partialCallStateStream(selector); } + /// The participants in this call, rate-limited at an interval that grows with + /// the participant count. + /// + /// Prefer this over `partialState((state) => state.callParticipants)` for + /// anything that renders the list: in a large call the raw state emits far + /// faster than a screen can usefully repaint. [CallState.callParticipants] + /// stays immediate, so a lookup that has to see a participant the moment + /// they join keeps working. + /// + /// Each window emits the most recent list to arrive during it, so the first + /// emission to a listener is delayed by up to one interval. Read + /// [CallState.callParticipants] for a value to render before then. + /// + /// Override the interval, or turn the throttle off altogether, with + /// [CallPreferences.participantsThrottleInterval]. The preference is read + /// once, when this stream is first listened to, and the throttle is shared by + /// every listener. + late final Stream> participantsStream = () { + final participants = partialState((state) => state.callParticipants); + final interval = + _stateManager.callState.preferences.participantsThrottleInterval; + + return (interval == null + ? participants + : participants.throttleByCollectionSize(interval: interval)) + .asBroadcastStream(); + }(); + SharedEmitter< ({ PeerConnectionStatsBundle publisherStatsBundle, diff --git a/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart b/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart index 1dc39f5dc..71ba86034 100644 --- a/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart +++ b/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart @@ -151,8 +151,7 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { // A silent participant who was already silent carries no new information, // so keep the existing instance and leave the list identical. if (levelInfo == null || - (!levelInfo.isSpeaking && - participant.isSpeaking == levelInfo.isSpeaking)) { + (!levelInfo.isSpeaking && !participant.isSpeaking)) { return participant; } diff --git a/packages/stream_video/lib/src/models/call_preferences.dart b/packages/stream_video/lib/src/models/call_preferences.dart index 913c13b24..d52855e83 100644 --- a/packages/stream_video/lib/src/models/call_preferences.dart +++ b/packages/stream_video/lib/src/models/call_preferences.dart @@ -2,6 +2,7 @@ import '../webrtc/e2ee/call_encryption_key.dart'; import 'audio_configuration_policy.dart'; import 'call_client_publish_options.dart'; import 'moderation_blur_config.dart'; +import 'participants_throttle.dart'; abstract class CallPreferences { /// The maximum duration to wait when establishing a connection to the call. @@ -62,6 +63,18 @@ abstract class CallPreferences { /// the client-level default `StreamVideoOptions.audioConfigurationPolicy`. AudioConfigurationPolicy? get audioConfigurationPolicy; + /// How long `Call.participantsStream` holds participant updates back, as a + /// function of the participant count. + /// + /// A large call emits participant updates far faster than a screen can + /// usefully repaint, so the default returns a longer interval the more + /// participants there are. Return a shorter one to trade CPU for latency, or + /// set this to `null` to emit every update. + /// + /// This does not affect `CallState.callParticipants`, which is always + /// up to date. + ParticipantsThrottleInterval? get participantsThrottleInterval; + /// Supplies the shared key for this call when no `EncryptionManager` has been /// attached to it by hand. /// @@ -97,6 +110,7 @@ class DefaultCallPreferences implements CallPreferences { this.closedCaptionsVisibleCaptions = 2, this.videoModerationConfig = const VideoModerationConfig.disabled(), this.audioConfigurationPolicy, + this.participantsThrottleInterval = defaultParticipantsThrottleInterval, this.encryptionKeyResolver, }); @@ -188,6 +202,14 @@ class DefaultCallPreferences implements CallPreferences { @override final AudioConfigurationPolicy? audioConfigurationPolicy; + /// How long `Call.participantsStream` holds participant updates back, as a + /// function of the participant count. See + /// [CallPreferences.participantsThrottleInterval]. + /// + /// Defaults to [defaultParticipantsThrottleInterval]. + @override + final ParticipantsThrottleInterval? participantsThrottleInterval; + /// Supplies the shared key for this call when no manager was attached by /// hand. See [CallPreferences.encryptionKeyResolver]. @override diff --git a/packages/stream_video/lib/src/models/models.dart b/packages/stream_video/lib/src/models/models.dart index 9b8793d81..87d17d411 100644 --- a/packages/stream_video/lib/src/models/models.dart +++ b/packages/stream_video/lib/src/models/models.dart @@ -22,6 +22,7 @@ export 'disconnect_reason.dart'; export 'guest_created_data.dart'; export 'moderation_blur_config.dart'; export 'multi_call_audio_policy.dart'; +export 'participants_throttle.dart'; export 'push_device.dart'; export 'push_provider.dart'; export 'queried_calls.dart'; diff --git a/packages/stream_video/lib/src/models/participants_throttle.dart b/packages/stream_video/lib/src/models/participants_throttle.dart new file mode 100644 index 000000000..9cbc0ee93 --- /dev/null +++ b/packages/stream_video/lib/src/models/participants_throttle.dart @@ -0,0 +1,16 @@ +/// How long `Call.participantsStream` holds participant updates back, given +/// how many participants the call currently has. +/// +/// See `CallPreferences.participantsThrottleInterval`. +typedef ParticipantsThrottleInterval = Duration Function(int participantCount); + +/// The interval `Call.participantsStream` uses unless a call overrides it. +/// +/// The tiers match the ones the iOS and Android SDKs use, so a livestream +/// updates at a comparable rate on every platform. +Duration defaultParticipantsThrottleInterval(int participantCount) { + if (participantCount < 16) return const Duration(milliseconds: 16); + if (participantCount < 50) return const Duration(milliseconds: 250); + if (participantCount < 100) return const Duration(milliseconds: 500); + return const Duration(seconds: 1); +} diff --git a/packages/stream_video/lib/src/utils/adaptive_throttle.dart b/packages/stream_video/lib/src/utils/adaptive_throttle.dart index 7dc83f4f3..fc09d9e89 100644 --- a/packages/stream_video/lib/src/utils/adaptive_throttle.dart +++ b/packages/stream_video/lib/src/utils/adaptive_throttle.dart @@ -2,35 +2,32 @@ import 'dart:async'; import 'package:rxdart/rxdart.dart'; -/// Emission intervals for [AdaptiveCollectionThrottleX], keyed on how large the -/// collection currently is. -/// -/// The tiers mirror the ones used by the iOS and Android SDKs, so a livestream -/// updates at a comparable rate on every platform. -Duration defaultCollectionThrottleInterval(int size) { - if (size < 16) return const Duration(milliseconds: 16); - if (size < 50) return const Duration(milliseconds: 250); - if (size < 100) return const Duration(milliseconds: 500); - return const Duration(seconds: 1); -} +import '../models/participants_throttle.dart'; extension AdaptiveCollectionThrottleX on Stream> { - /// Rate-limits this stream, emitting at most once per interval, where the - /// interval grows with the size of the most recent list. + /// Rate-limits this stream to one value per window, where [interval] decides + /// how long that window is from the size of the list that opened it. /// - /// The first value of each window is emitted immediately and the last one is - /// emitted when the window closes, so a change is never dropped — only - /// collapsed with the ones around it. A window that saw a single value closes - /// without repeating it. + /// The value emitted is the most recent one to arrive during the window, so + /// a change is never dropped — only collapsed with the ones around it. The + /// window opens on the first value after an idle period and that value is + /// held until it closes, which means the first emission to a new listener is + /// delayed by up to one interval. Read `CallState.callParticipants` for a + /// value to start from. /// - /// Use it for participant lists feeding the UI. Don't use it for values a - /// caller acts on rather than renders, such as the call status, where a delay - /// of up to a second would be visible as lag. + /// [interval] is evaluated once per window, when it opens. A value arriving + /// mid-window does not restart or re-measure it, so a change in list size + /// takes effect on the next window. Stream> throttleByCollectionSize({ - Duration Function(int size) interval = defaultCollectionThrottleInterval, + required ParticipantsThrottleInterval interval, }) { return throttle( (value) => TimerStream(null, interval(value.length)), + // Leading emissions would double the rate: `eventAfterLastWindow` closes + // a window before the next value reopens it, so a continuous source gets + // both the trailing value and the next value as the new window's leading + // one. Trailing alone makes the interval mean what it says. + leading: false, trailing: true, ).distinct(identical); } diff --git a/packages/stream_video/lib/stream_video.dart b/packages/stream_video/lib/stream_video.dart index 9e6ff2dd3..f51f3cbea 100644 --- a/packages/stream_video/lib/stream_video.dart +++ b/packages/stream_video/lib/stream_video.dart @@ -57,7 +57,6 @@ export 'src/state_emitter.dart' show MutableStateEmitter, StateEmitter; export 'src/stream_video.dart'; export 'src/token/token.dart'; export 'src/types/other.dart'; -export 'src/utils/adaptive_throttle.dart'; export 'src/utils/none.dart'; export 'src/utils/result.dart'; export 'src/utils/string.dart'; diff --git a/packages/stream_video/test/src/utils/adaptive_throttle_test.dart b/packages/stream_video/test/src/utils/adaptive_throttle_test.dart index 0c7f24b96..dbfb18f2b 100644 --- a/packages/stream_video/test/src/utils/adaptive_throttle_test.dart +++ b/packages/stream_video/test/src/utils/adaptive_throttle_test.dart @@ -1,29 +1,30 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video/src/utils/adaptive_throttle.dart'; import 'package:stream_video/stream_video.dart'; void main() { test('interval grows with the size of the collection', () { expect( - defaultCollectionThrottleInterval(1), + defaultParticipantsThrottleInterval(1), const Duration(milliseconds: 16), ); expect( - defaultCollectionThrottleInterval(20), + defaultParticipantsThrottleInterval(20), const Duration(milliseconds: 250), ); expect( - defaultCollectionThrottleInterval(60), + defaultParticipantsThrottleInterval(60), const Duration(milliseconds: 500), ); expect( - defaultCollectionThrottleInterval(500), + defaultParticipantsThrottleInterval(500), const Duration(seconds: 1), ); }); - test('emits the first and the last value of a burst', () async { + test('emits the last value of a burst', () async { final controller = StreamController>(); final received = >[]; @@ -41,7 +42,6 @@ void main() { await Future.delayed(const Duration(milliseconds: 120)); expect(received, [ - [1], [3], ]); @@ -49,38 +49,88 @@ void main() { await controller.close(); }); + test('emits once per window under a continuous source', () async { + final controller = StreamController>(); + final received = >[]; + + final subscription = controller.stream + .throttleByCollectionSize( + interval: (_) => const Duration(milliseconds: 100), + ) + .listen(received.add); + + var value = 0; + final timer = Timer.periodic( + const Duration(milliseconds: 10), + (_) => controller.add([value++]), + ); + await Future.delayed(const Duration(milliseconds: 520)); + timer.cancel(); + + // Five 100ms windows over ~500ms. A leading emission on top of the + // trailing one would roughly double this. + expect(received.length, inInclusiveRange(4, 6)); + + await subscription.cancel(); + await controller.close(); + }); + test('a larger collection is throttled for longer', () async { final controller = StreamController>(); final received = >[]; final subscription = controller.stream .throttleByCollectionSize( - interval: (size) => Duration(milliseconds: size < 3 ? 10 : 200), + interval: (size) => Duration(milliseconds: size < 3 ? 20 : 300), ) .listen(received.add); - // A small list opens a short window, so the next value is let through. + // A small list opens a short window, so its value lands quickly. controller.add([1]); - await Future.delayed(const Duration(milliseconds: 30)); - controller.add([1, 2]); - await Future.delayed(const Duration(milliseconds: 30)); + await Future.delayed(const Duration(milliseconds: 60)); expect(received, [ [1], - [1, 2], ]); - // A large list opens a long window that holds back the values behind it. + // A large list opens a long window that holds everything behind it. controller ..add([1, 2, 3]) ..add([1, 2, 3, 4]) ..add([1, 2, 3, 4, 5]); - await Future.delayed(const Duration(milliseconds: 60)); - expect(received.length, 3); + await Future.delayed(const Duration(milliseconds: 100)); + expect(received.length, 1); - await Future.delayed(const Duration(milliseconds: 250)); + await Future.delayed(const Duration(milliseconds: 300)); expect(received.last, [1, 2, 3, 4, 5]); await subscription.cancel(); await controller.close(); }); + + group('CallPreferences.participantsThrottleInterval', () { + test('defaults to the size-based tiers', () { + expect( + DefaultCallPreferences().participantsThrottleInterval, + defaultParticipantsThrottleInterval, + ); + }); + + test('can be overridden with a fixed interval', () { + const fixed = Duration(milliseconds: 100); + final preferences = DefaultCallPreferences( + participantsThrottleInterval: (_) => fixed, + ); + + expect(preferences.participantsThrottleInterval!(1), fixed); + expect(preferences.participantsThrottleInterval!(500), fixed); + }); + + test('can be turned off', () { + final preferences = DefaultCallPreferences( + participantsThrottleInterval: null, + ); + + expect(preferences.participantsThrottleInterval, isNull); + }); + }); } diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index b0507b5ff..461062683 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -2,7 +2,7 @@ ### 🔄 Changed -- Participant list subscriptions are now throttled at an interval that grows with the participant count. +- Participant list widgets now subscribe to `Call.participantsStream`, which is throttled by participant count. - `StreamCallParticipants` and `StreamLivestreamHosts` no longer rebuild when an update leaves the rendered participants unchanged. ## 1.6.0 diff --git a/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart b/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart index cd0d14fc7..3e325b387 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart @@ -127,10 +127,9 @@ class _StreamCallParticipantsState extends State ); if (widget.participants == null) { - _participantsSubscription = widget.call - .partialState((state) => state.callParticipants) - .throttleByCollectionSize() - .listen(recalculateParticipants); + _participantsSubscription = widget.call.participantsStream.listen( + recalculateParticipants, + ); } } @@ -155,10 +154,9 @@ class _StreamCallParticipantsState extends State } } else if (widget.call != oldWidget.call) { _participantsSubscription?.cancel(); - _participantsSubscription = widget.call - .partialState((state) => state.callParticipants) - .throttleByCollectionSize() - .listen(recalculateParticipants); + _participantsSubscription = widget.call.participantsStream.listen( + recalculateParticipants, + ); recalculateParticipants(widget.call.state.value.callParticipants); } diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart index f189002c4..783e5ee17 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart @@ -55,10 +55,9 @@ class _AndroidPipOverlayState extends State super.initState(); recalculateParticipants(widget.call.state.value.callParticipants); - _participantsSubscription = widget.call - .partialState((state) => state.callParticipants) - .throttleByCollectionSize() - .listen(recalculateParticipants); + _participantsSubscription = widget.call.participantsStream.listen( + recalculateParticipants, + ); } @override From 093d31224fdcf000010fa5d6a74efb909a6f6fd3 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 16 Sep 2026 16:42:17 +0200 Subject: [PATCH 03/13] fix(ui): keep the livestream widgets off raw participant state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LivestreamContent` selected `(callParticipants, status)` as a record. Dart compares a record's `List` field by identity, so it missed the `ListEquality` path in `partialCallStateStream`'s distinct and rebuilt on every participant update, re-running the host filter over the full list. Split it: status still comes off the raw state, so a disconnect is acted on at once, and the participants come through `Call.participantsStream`. `LivestreamBackstageContent` renders `participants.length` and nothing else, so it selects the count instead. An int in a record compares by value, which makes the distinct work and keeps the number prompt — throttling it would have delayed a count for no benefit. Add `CallParticipantsBuilder` for the first case. It seeds `initialData` from the current call state, so a consumer does not wait out the first throttle window, which at the 1s tier would leave a livestream blank. Co-Authored-By: Claude Opus 5 --- packages/stream_video_flutter/CHANGELOG.md | 6 + .../livestream_backstage_content.dart | 14 +- .../src/livestream/livestream_content.dart | 222 +++++++++--------- .../widgets/partial_call_state_builder.dart | 35 +++ 4 files changed, 164 insertions(+), 113 deletions(-) diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 461062683..9d7a1fb72 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -1,9 +1,15 @@ ## Upcoming +### ✅ Added + +- Added `CallParticipantsBuilder`, which builds from `Call.participantsStream` and seeds its first frame from the current call state. + ### 🔄 Changed - Participant list widgets now subscribe to `Call.participantsStream`, which is throttled by participant count. - `StreamCallParticipants` and `StreamLivestreamHosts` no longer rebuild when an update leaves the rendered participants unchanged. +- `LivestreamContent` renders participants from `Call.participantsStream` instead of the raw call state. Call status is still read immediately. +- `LivestreamBackstageContent` only rebuilds when the participant count changes. ## 1.6.0 diff --git a/packages/stream_video_flutter/lib/src/livestream/livestream_backstage_content.dart b/packages/stream_video_flutter/lib/src/livestream/livestream_backstage_content.dart index 13bc7cb35..7a04d2e56 100644 --- a/packages/stream_video_flutter/lib/src/livestream/livestream_backstage_content.dart +++ b/packages/stream_video_flutter/lib/src/livestream/livestream_backstage_content.dart @@ -97,10 +97,14 @@ class _LivestreamBackstageContentState return PartialCallStateBuilder( call: widget.call, - selector: (state) => - (callParticipants: state.callParticipants, startsAt: state.startsAt), + // Selecting the count rather than the list keeps this off every + // participant update that doesn't change how many there are. + selector: (state) => ( + participantCount: state.callParticipants.length, + startsAt: state.startsAt, + ), builder: (context, callData) { - final participants = callData.callParticipants; + final participantCount = callData.participantCount; final startsAt = callData.startsAt; return Scaffold( @@ -122,11 +126,11 @@ class _LivestreamBackstageContentState style: liveTheme.backstageCounterTextStyle, ), ], - if (participants.isNotEmpty) ...[ + if (participantCount > 0) ...[ const SizedBox(height: 12), Text( translations.livestreamBackstageParticipants( - participants.length, + participantCount, ), style: liveTheme.backstageParticipantsTextStyle, ), diff --git a/packages/stream_video_flutter/lib/src/livestream/livestream_content.dart b/packages/stream_video_flutter/lib/src/livestream/livestream_content.dart index 1ea25f7ae..c327a1288 100644 --- a/packages/stream_video_flutter/lib/src/livestream/livestream_content.dart +++ b/packages/stream_video_flutter/lib/src/livestream/livestream_content.dart @@ -255,123 +255,129 @@ class _LivestreamContentState extends State { final pipEnabled = widget.pictureInPictureConfiguration.enablePictureInPicture; + // Status stays on the raw state so a disconnect is acted on at once; the + // participants come through the throttled stream. return PartialCallStateBuilder( call: call, - selector: (state) => - (callParticipants: state.callParticipants, status: state.status), - builder: (context, callData) { - final participants = callData.callParticipants; - final status = callData.status; - - late Widget bodyWidget; - if (status.isConnected || - status.isFastReconnecting || - status.isMigrating) { - final streamingParticipants = - widget.livestreamHostsParticipantsFilter?.call(participants) ?? - _defaultStreamingParticipantsFilter(participants); - - if (streamingParticipants.isEmpty) { - bodyWidget = - widget.livestreamHostsUnavailableBuilder?.call( - context, - LivestreamHostsUnavailableProperties(call), - ) ?? - Center( - child: Text( - translations.livestreamHostNotAvailable, - style: theme.livestreamTheme.callStateButtonTextStyle, - ), - ); - } else { - bodyWidget = Stack( - children: [ - if (CurrentPlatform.isIos && pipEnabled) - SizedBox( - height: 600, - width: 300, - child: StreamPictureInPictureUiKitView( - call: call, - pictureInPictureConfiguration: - widget.pictureInPictureConfiguration, - ), - ), - if (CurrentPlatform.isAndroid && pipEnabled) - StreamPictureInPictureAndroidView( - call: call, - configuration: widget.pictureInPictureConfiguration, - ), - widget.livestreamHostsParticipantBuilder?.call( + selector: (state) => state.status, + builder: (context, status) { + return CallParticipantsBuilder( + call: call, + builder: (context, participants) { + late Widget bodyWidget; + if (status.isConnected || + status.isFastReconnecting || + status.isMigrating) { + final streamingParticipants = + widget.livestreamHostsParticipantsFilter?.call( + participants, + ) ?? + _defaultStreamingParticipantsFilter(participants); + + if (streamingParticipants.isEmpty) { + bodyWidget = + widget.livestreamHostsUnavailableBuilder?.call( context, - LivestreamHostsParticipantProperties( - call: call, - hosts: streamingParticipants, - ), + LivestreamHostsUnavailableProperties(call), ) ?? - _defaultHostsParticipantBuilder( - context, - LivestreamHostsParticipantProperties( + Center( + child: Text( + translations.livestreamHostNotAvailable, + style: theme.livestreamTheme.callStateButtonTextStyle, + ), + ); + } else { + bodyWidget = Stack( + children: [ + if (CurrentPlatform.isIos && pipEnabled) + SizedBox( + height: 600, + width: 300, + child: StreamPictureInPictureUiKitView( + call: call, + pictureInPictureConfiguration: + widget.pictureInPictureConfiguration, + ), + ), + if (CurrentPlatform.isAndroid && pipEnabled) + StreamPictureInPictureAndroidView( call: call, - hosts: streamingParticipants, + configuration: widget.pictureInPictureConfiguration, ), - ), - if (status.isFastReconnecting) - widget.livestreamFastReconnectingOverlayBuilder?.call( - context, - LivestreamFastReconnectingProperties(call), - ) ?? - const Positioned( - top: 25, - left: 25, - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, + widget.livestreamHostsParticipantBuilder?.call( + context, + LivestreamHostsParticipantProperties( + call: call, + hosts: streamingParticipants, + ), + ) ?? + _defaultHostsParticipantBuilder( + context, + LivestreamHostsParticipantProperties( + call: call, + hosts: streamingParticipants, ), ), - ), - ], + if (status.isFastReconnecting) + widget.livestreamFastReconnectingOverlayBuilder?.call( + context, + LivestreamFastReconnectingProperties(call), + ) ?? + const Positioned( + top: 25, + left: 25, + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ), + ), + ], + ); + } + } else { + final isMigrating = status.isMigrating; + final isReconnecting = status.isReconnecting; + final statusText = isReconnecting ? 'Reconnecting' : 'Connecting'; + + bodyWidget = + widget.livestreamNotConnectedBuilder?.call( + context, + LivestreamNotConnectedProperties( + call, + isMigrating: isMigrating, + isReconnecting: isReconnecting, + ), + ) ?? + Center( + child: Text( + statusText, + style: theme.livestreamTheme.callStateButtonTextStyle, + ), + ); + } + + return Scaffold( + backgroundColor: theme.colorTheme.livestreamBackground, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + automaticallyImplyLeading: false, + leading: widget.backButtonBuilder?.call(context), + ), + extendBodyBehindAppBar: true, + body: Stack( + children: [ + bodyWidget, + if (widget.displayDiagnostics) + CallDiagnosticsContent(call: call), + ], + ), ); - } - } else { - final isMigrating = status.isMigrating; - final isReconnecting = status.isReconnecting; - final statusText = isReconnecting ? 'Reconnecting' : 'Connecting'; - - bodyWidget = - widget.livestreamNotConnectedBuilder?.call( - context, - LivestreamNotConnectedProperties( - call, - isMigrating: isMigrating, - isReconnecting: isReconnecting, - ), - ) ?? - Center( - child: Text( - statusText, - style: theme.livestreamTheme.callStateButtonTextStyle, - ), - ); - } - - return Scaffold( - backgroundColor: theme.colorTheme.livestreamBackground, - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - automaticallyImplyLeading: false, - leading: widget.backButtonBuilder?.call(context), - ), - extendBodyBehindAppBar: true, - body: Stack( - children: [ - bodyWidget, - if (widget.displayDiagnostics) CallDiagnosticsContent(call: call), - ], - ), + }, ); }, ); diff --git a/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart b/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart index 2daae55fd..f4035a997 100644 --- a/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart +++ b/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart @@ -27,6 +27,41 @@ class PartialCallStateBuilder extends StatelessWidget { } } +/// Convenience widget to build a part of the call screen from the call's +/// participants. +/// +/// Reads [Call.participantsStream], which is rate-limited by participant count, +/// and seeds the first frame from `call.state.value.callParticipants` so +/// nothing waits on the first throttle window. +/// +/// Use this wherever the participants themselves get rendered. When only a +/// derived value is needed — a count, whether anyone is speaking — select that +/// through [PartialCallStateBuilder] instead, so the widget doesn't rebuild on +/// participant updates that leave it the same. +class CallParticipantsBuilder extends StatelessWidget { + const CallParticipantsBuilder({ + required this.call, + required this.builder, + super.key, + }); + + final Call call; + final Widget Function( + BuildContext context, + List participants, + ) + builder; + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + stream: call.participantsStream, + initialData: call.state.value.callParticipants, + builder: (context, snapshot) => builder(context, snapshot.data!), + ); + } +} + /// Builder for parts of the call screen that need a regular Widget. typedef CallWidgetBuilder = Widget Function( From 36158ad15ba9ae3eb100c6bcddaea58415a54cca Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 16 Sep 2026 16:59:59 +0200 Subject: [PATCH 04/13] fix(llc,ui): address the PR #1354 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace rxdart's `throttle` with a hand-rolled transformer. Its `eventAfterLastWindow` strategy left the sink open when the source closed with no window running, so `participantsStream` never completed — the normal case, since a stable participant list means an idle throttle. It also gated its close-time flush on `queue.length > 1`, silently dropping a lone held value; the disconnect that sets `callParticipants` to empty could land inside a window and never reach a listener. Both reproduced, both now covered by tests. This is the third quirk of that operator on this path after the leading-emission doubling, hence writing it out. `participantsStream` is a getter again rather than a `late final` over `asBroadcastStream()`. The broadcast never tore down — it kept consuming the source with zero listeners for the life of the Call — and gave late listeners no current value. A fresh chain per listener replays from the upstream BehaviorSubject, and dies with its subscription. `sfuDominantSpeakerChanged` guarded with `firstWhereOrNull`, which assumes one flagged participant. Nothing enforces that: `sfuJoinResponse` and `sfuParticipantUpdated` both take the flag off the wire. With two flagged and the event matching the first, the handler returned and the stale flag survived — the old unconditional pass cleared it. Now requires the flagged set to be exactly the event's participant. `CallParticipantsBuilder` used `snapshot.data!`. `StreamBuilder` discards data on an error snapshot, so that threw a null check over the real error. Falls back to the current call state. Also: document that audio levels hold while a participant is silent, which is a public behaviour change; anchor the tier provenance to a Swift version and date rather than an unverifiable claim; correct the preference-read timing docs. Tests: throttle suite rewritten on `fakeAsync` with the tier boundaries and completion cases covered, plus first coverage for `sfuInboundStateNotification` and the sorting mixin. Co-Authored-By: Claude Opus 5 --- packages/stream_video/CHANGELOG.md | 1 + packages/stream_video/lib/src/call/call.dart | 23 +- .../call/state/mixins/state_sfu_mixin.dart | 20 +- .../lib/src/models/call_preferences.dart | 4 +- .../lib/src/models/participants_throttle.dart | 6 +- .../lib/src/utils/adaptive_throttle.dart | 87 ++++- .../src/call/state/state_sfu_mixin_test.dart | 130 +++++++ .../src/utils/adaptive_throttle_test.dart | 339 +++++++++++++----- .../widgets/partial_call_state_builder.dart | 7 +- .../call_participants_sorting_mixin_test.dart | 176 +++++++++ 10 files changed, 664 insertions(+), 129 deletions(-) create mode 100644 packages/stream_video_flutter/test/src/call_participants/call_participants_sorting_mixin_test.dart diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index 713236ef0..21fad16c2 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -3,6 +3,7 @@ ### 🔄 Changed - SFU participant events no longer emit a new call state when they leave every participant unchanged. +- `CallParticipantState.audioLevel` and `audioLevels` now hold at their last value while a participant is silent, instead of tracking every below-threshold reading. - Added `Call.participantsStream`, which emits the participant list at an interval that grows with the participant count. `CallState.callParticipants` is unchanged. - Added `CallPreferences.participantsThrottleInterval` to override that interval, or set it to `null` to emit every update. diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index 8cec7c087..0925fd414 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -442,24 +442,23 @@ class Call { /// stays immediate, so a lookup that has to see a participant the moment /// they join keeps working. /// - /// Each window emits the most recent list to arrive during it, so the first - /// emission to a listener is delayed by up to one interval. Read + /// Each window emits the most recent list to arrive during it, so a + /// listener's first value is delayed by up to one interval. Read /// [CallState.callParticipants] for a value to render before then. /// - /// Override the interval, or turn the throttle off altogether, with - /// [CallPreferences.participantsThrottleInterval]. The preference is read - /// once, when this stream is first listened to, and the throttle is shared by - /// every listener. - late final Stream> participantsStream = () { + /// Every listener gets its own window, and it is torn down with that + /// listener's subscription. The interval comes from + /// [CallPreferences.participantsThrottleInterval], read each time this getter + /// is called, so a later `updateCallPreferences` reaches new listeners; set + /// it to null to emit every update. + Stream> get participantsStream { final participants = partialState((state) => state.callParticipants); final interval = _stateManager.callState.preferences.participantsThrottleInterval; - return (interval == null - ? participants - : participants.throttleByCollectionSize(interval: interval)) - .asBroadcastStream(); - }(); + if (interval == null) return participants; + return participants.throttleByCollectionSize(interval: interval); + } SharedEmitter< ({ diff --git a/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart b/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart index 71ba86034..75b929631 100644 --- a/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart +++ b/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart @@ -148,8 +148,9 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { participant.sessionId, )]; - // A silent participant who was already silent carries no new information, - // so keep the existing instance and leave the list identical. + // A participant who was silent and still is keeps their existing + // instance, so the list stays identical. Their `audioLevel` and + // `audioLevels` hold at the last value from while they were speaking. if (levelInfo == null || (!levelInfo.isSpeaking && !participant.isSpeaking)) { return participant; @@ -174,13 +175,16 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { () => '[sfuDominantSpeakerChanged] ${state.sessionId}; event: $event', ); - final current = state.callParticipants.firstWhereOrNull( - (participant) => participant.isDominantSpeaker, - ); + // Nothing stops two participants carrying the flag — `sfuJoinResponse` and + // `sfuParticipantUpdated` both take it straight off the wire — so this only + // skips the pass when the event's participant is the sole one marked. + final flagged = state.callParticipants + .where((participant) => participant.isDominantSpeaker) + .toList(); - if (current != null && - current.userId == event.userId && - current.sessionId == event.sessionId) { + if (flagged.length == 1 && + flagged.first.userId == event.userId && + flagged.first.sessionId == event.sessionId) { return; } diff --git a/packages/stream_video/lib/src/models/call_preferences.dart b/packages/stream_video/lib/src/models/call_preferences.dart index d52855e83..3056b1494 100644 --- a/packages/stream_video/lib/src/models/call_preferences.dart +++ b/packages/stream_video/lib/src/models/call_preferences.dart @@ -72,7 +72,9 @@ abstract class CallPreferences { /// set this to `null` to emit every update. /// /// This does not affect `CallState.callParticipants`, which is always - /// up to date. + /// up to date. It is read each time `Call.participantsStream` is used, so + /// changing it through `updateCallPreferences` reaches new listeners but + /// leaves existing ones on the interval they subscribed with. ParticipantsThrottleInterval? get participantsThrottleInterval; /// Supplies the shared key for this call when no `EncryptionManager` has been diff --git a/packages/stream_video/lib/src/models/participants_throttle.dart b/packages/stream_video/lib/src/models/participants_throttle.dart index 9cbc0ee93..7bd0ddc86 100644 --- a/packages/stream_video/lib/src/models/participants_throttle.dart +++ b/packages/stream_video/lib/src/models/participants_throttle.dart @@ -6,8 +6,10 @@ typedef ParticipantsThrottleInterval = Duration Function(int participantCount); /// The interval `Call.participantsStream` uses unless a call overrides it. /// -/// The tiers match the ones the iOS and Android SDKs use, so a livestream -/// updates at a comparable rate on every platform. +/// The tiers were taken from stream-video-swift's +/// `CollectionDelayedUpdateObserver` (v1.52.0, September 2026). Nothing in this +/// repository keeps them in step, so treat that as where they came from rather +/// than as a guarantee they still match. Duration defaultParticipantsThrottleInterval(int participantCount) { if (participantCount < 16) return const Duration(milliseconds: 16); if (participantCount < 50) return const Duration(milliseconds: 250); diff --git a/packages/stream_video/lib/src/utils/adaptive_throttle.dart b/packages/stream_video/lib/src/utils/adaptive_throttle.dart index fc09d9e89..e8bf772b1 100644 --- a/packages/stream_video/lib/src/utils/adaptive_throttle.dart +++ b/packages/stream_video/lib/src/utils/adaptive_throttle.dart @@ -1,8 +1,7 @@ import 'dart:async'; -import 'package:rxdart/rxdart.dart'; - -import '../models/participants_throttle.dart'; +/// How long a window lasts, given the size of the collection that opened it. +typedef CollectionThrottleInterval = Duration Function(int size); extension AdaptiveCollectionThrottleX on Stream> { /// Rate-limits this stream to one value per window, where [interval] decides @@ -11,24 +10,80 @@ extension AdaptiveCollectionThrottleX on Stream> { /// The value emitted is the most recent one to arrive during the window, so /// a change is never dropped — only collapsed with the ones around it. The /// window opens on the first value after an idle period and that value is - /// held until it closes, which means the first emission to a new listener is - /// delayed by up to one interval. Read `CallState.callParticipants` for a - /// value to start from. + /// held until it closes, which means a listener's first value is delayed by + /// up to one interval. /// /// [interval] is evaluated once per window, when it opens. A value arriving /// mid-window does not restart or re-measure it, so a change in list size /// takes effect on the next window. + /// + /// When the source closes, anything still held is emitted before this stream + /// closes, whether or not a window was open. Stream> throttleByCollectionSize({ - required ParticipantsThrottleInterval interval, + required CollectionThrottleInterval interval, }) { - return throttle( - (value) => TimerStream(null, interval(value.length)), - // Leading emissions would double the rate: `eventAfterLastWindow` closes - // a window before the next value reopens it, so a continuous source gets - // both the trailing value and the next value as the new window's leading - // one. Trailing alone makes the interval mean what it says. - leading: false, - trailing: true, - ).distinct(identical); + return transform(_CollectionThrottle(interval)); + } +} + +/// Written by hand rather than with `rxdart`'s `throttle`, whose +/// `eventAfterLastWindow` strategy leaves the sink open when the source closes +/// with no window running, and drops a lone held value when it closes with one. +class _CollectionThrottle extends StreamTransformerBase, List> { + const _CollectionThrottle(this._interval); + + final CollectionThrottleInterval _interval; + + @override + Stream> bind(Stream> stream) { + late final StreamController> controller; + // Cancelled in the controller's onCancel below. + // ignore: cancel_subscriptions + StreamSubscription>? subscription; + Timer? window; + List? held; + + void emitHeld() { + final value = held; + held = null; + if (value != null) controller.add(value); + } + + void onData(List value) { + held = value; + window ??= Timer(_interval(value.length), () { + window = null; + emitHeld(); + }); + } + + void onDone() { + window?.cancel(); + window = null; + // Nothing more can arrive to collapse the held value with, so it goes + // out now instead of waiting for a window that no longer matters. + emitHeld(); + controller.close(); + } + + controller = StreamController>( + onListen: () => subscription = stream.listen( + onData, + onError: controller.addError, + onDone: onDone, + ), + onPause: () => subscription?.pause(), + onResume: () => subscription?.resume(), + onCancel: () { + window?.cancel(); + window = null; + held = null; + final sub = subscription; + subscription = null; + return sub?.cancel(); + }, + ); + + return controller.stream; } } diff --git a/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart b/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart index d067eb84b..7ce782c1f 100644 --- a/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart +++ b/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart @@ -3,6 +3,7 @@ import 'package:stream_video/src/call/state/call_state_notifier.dart'; import 'package:stream_video/src/sfu/data/events/sfu_events.dart'; import 'package:stream_video/src/sfu/data/models/sfu_audio_level.dart'; import 'package:stream_video/src/sfu/data/models/sfu_connection_info.dart'; +import 'package:stream_video/src/sfu/data/models/sfu_inbound_video_state.dart'; import 'package:stream_video/stream_video.dart'; CallParticipantState _participant({ @@ -223,4 +224,133 @@ void main() { expect(participants[1].isDominantSpeaker, isTrue); }); }); + + group('sfuDominantSpeakerChanged with a stale flag', () { + test('clears a second flagged participant even when the event matches ' + 'the first', () { + final notifier = _notifier([ + _participant(userId: 'alice', isDominantSpeaker: true), + _participant(userId: 'bob', isDominantSpeaker: true), + ]); + + notifier.sfuDominantSpeakerChanged( + const SfuDominantSpeakerChangedEvent( + userId: 'alice', + sessionId: 'alice-session', + ), + ); + + expect( + notifier.callState.callParticipants + .where((it) => it.isDominantSpeaker) + .map((it) => it.userId), + ['alice'], + ); + }); + }); + + group('sfuInboundStateNotification', () { + SfuInboundVideoState inbound( + String userId, + SfuTrackType trackType, { + required bool paused, + }) { + return SfuInboundVideoState( + userId: userId, + sessionId: '$userId-session', + trackType: trackType, + paused: paused, + ); + } + + test('applies several track states for one participant', () { + final notifier = _notifier([_participant(userId: 'alice')]); + + notifier.sfuInboundStateNotification( + SfuInboundStateNotificationEvent( + inboundVideoStates: [ + inbound('alice', SfuTrackType.video, paused: true), + inbound('alice', SfuTrackType.screenShare, paused: true), + ], + ), + ); + + expect( + notifier.callState.callParticipants.single.pausedTracks, + {SfuTrackType.video, SfuTrackType.screenShare}, + ); + }); + + test('unpauses on the round trip', () { + final notifier = _notifier([_participant(userId: 'alice')]); + + notifier.sfuInboundStateNotification( + SfuInboundStateNotificationEvent( + inboundVideoStates: [ + inbound('alice', SfuTrackType.video, paused: true), + ], + ), + ); + expect(notifier.callState.callParticipants.single.pausedTracks, { + SfuTrackType.video, + }); + + notifier.sfuInboundStateNotification( + SfuInboundStateNotificationEvent( + inboundVideoStates: [ + inbound('alice', SfuTrackType.video, paused: false), + ], + ), + ); + expect( + notifier.callState.callParticipants.single.pausedTracks, + isEmpty, + reason: 'a track must not stay flagged paused', + ); + }); + + test('does not emit when the paused set is unchanged', () { + final notifier = _notifier([_participant(userId: 'alice')]); + + notifier.sfuInboundStateNotification( + SfuInboundStateNotificationEvent( + inboundVideoStates: [ + inbound('alice', SfuTrackType.video, paused: true), + ], + ), + ); + final before = notifier.callState.callParticipants; + + notifier.sfuInboundStateNotification( + SfuInboundStateNotificationEvent( + inboundVideoStates: [ + inbound('alice', SfuTrackType.video, paused: true), + ], + ), + ); + + expect(identical(notifier.callState.callParticipants, before), isTrue); + }); + + test('leaves participants the event does not mention alone', () { + final notifier = _notifier([ + _participant(userId: 'alice'), + _participant(userId: 'bob'), + ]); + final bobBefore = notifier.callState.callParticipants[1]; + + notifier.sfuInboundStateNotification( + SfuInboundStateNotificationEvent( + inboundVideoStates: [ + inbound('alice', SfuTrackType.video, paused: true), + ], + ), + ); + + expect( + identical(notifier.callState.callParticipants[1], bobBefore), + isTrue, + ); + }); + }); } diff --git a/packages/stream_video/test/src/utils/adaptive_throttle_test.dart b/packages/stream_video/test/src/utils/adaptive_throttle_test.dart index dbfb18f2b..e71f8c29f 100644 --- a/packages/stream_video/test/src/utils/adaptive_throttle_test.dart +++ b/packages/stream_video/test/src/utils/adaptive_throttle_test.dart @@ -1,110 +1,271 @@ import 'dart:async'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_video/src/utils/adaptive_throttle.dart'; import 'package:stream_video/stream_video.dart'; +/// Drives a source through the throttle inside a `fakeAsync` zone and hands the +/// body a recorder for what came out. +void _throttled( + CollectionThrottleInterval interval, + void Function( + FakeAsync async, + StreamController> source, + List> received, + List done, + ) + body, +) { + fakeAsync((async) { + // Closed by the caller where it matters; these all end with the zone. + // ignore: close_sinks + final source = StreamController>(); + final received = >[]; + final done = []; + + final subscription = source.stream + .throttleByCollectionSize(interval: interval) + .listen(received.add, onDone: () => done.add(true)); + + body(async, source, received, done); + + subscription.cancel(); + async.flushMicrotasks(); + }); +} + void main() { - test('interval grows with the size of the collection', () { - expect( - defaultParticipantsThrottleInterval(1), - const Duration(milliseconds: 16), - ); - expect( - defaultParticipantsThrottleInterval(20), - const Duration(milliseconds: 250), - ); - expect( - defaultParticipantsThrottleInterval(60), - const Duration(milliseconds: 500), - ); - expect( - defaultParticipantsThrottleInterval(500), - const Duration(seconds: 1), - ); + group('interval tiers', () { + test('grow with the size of the collection', () { + expect( + defaultParticipantsThrottleInterval(1), + const Duration(milliseconds: 16), + ); + expect( + defaultParticipantsThrottleInterval(20), + const Duration(milliseconds: 250), + ); + expect( + defaultParticipantsThrottleInterval(60), + const Duration(milliseconds: 500), + ); + expect( + defaultParticipantsThrottleInterval(500), + const Duration(seconds: 1), + ); + }); + + test('change on the documented boundaries', () { + expect( + defaultParticipantsThrottleInterval(15), + const Duration(milliseconds: 16), + ); + expect( + defaultParticipantsThrottleInterval(16), + const Duration(milliseconds: 250), + ); + expect( + defaultParticipantsThrottleInterval(49), + const Duration(milliseconds: 250), + ); + expect( + defaultParticipantsThrottleInterval(50), + const Duration(milliseconds: 500), + ); + expect( + defaultParticipantsThrottleInterval(99), + const Duration(milliseconds: 500), + ); + expect( + defaultParticipantsThrottleInterval(100), + const Duration(seconds: 1), + ); + }); }); - test('emits the last value of a burst', () async { - final controller = StreamController>(); - final received = >[]; + group('throttleByCollectionSize', () { + test('emits the last value of a burst, once the window closes', () { + _throttled((_) => const Duration(milliseconds: 50), ( + async, + source, + received, + _, + ) { + source + ..add([1]) + ..add([2]) + ..add([3]); + + async.elapse(const Duration(milliseconds: 49)); + expect(received, isEmpty, reason: 'window has not closed yet'); - final subscription = controller.stream - .throttleByCollectionSize( - interval: (_) => const Duration(milliseconds: 50), - ) - .listen(received.add); + async.elapse(const Duration(milliseconds: 1)); + expect(received, [ + [3], + ]); + }); + }); - controller - ..add([1]) - ..add([2]) - ..add([3]); + test('emits exactly once per window under a continuous source', () { + _throttled((_) => const Duration(milliseconds: 100), ( + async, + source, + received, + _, + ) { + for (var tick = 0; tick < 50; tick++) { + source.add([tick]); + async.elapse(const Duration(milliseconds: 10)); + } - await Future.delayed(const Duration(milliseconds: 120)); + // 500ms of source at 10ms intervals, 100ms windows. A leading emission + // on top of the trailing one would roughly double this. + expect(received.length, 5); + }); + }); - expect(received, [ - [3], - ]); + test('measures the window from the list that opened it', () { + _throttled((size) => Duration(milliseconds: size < 3 ? 20 : 300), ( + async, + source, + received, + _, + ) { + // A small list opens a short window. + source.add([1]); + async.elapse(const Duration(milliseconds: 20)); + expect(received, [ + [1], + ]); - await subscription.cancel(); - await controller.close(); - }); + // A large list opens a long one, and growing mid-window does not + // re-measure it. + source + ..add([1, 2, 3]) + ..add([1, 2, 3, 4]) + ..add([1, 2, 3, 4, 5]); - test('emits once per window under a continuous source', () async { - final controller = StreamController>(); - final received = >[]; + async.elapse(const Duration(milliseconds: 299)); + expect(received.length, 1, reason: 'still inside the long window'); - final subscription = controller.stream - .throttleByCollectionSize( - interval: (_) => const Duration(milliseconds: 100), - ) - .listen(received.add); - - var value = 0; - final timer = Timer.periodic( - const Duration(milliseconds: 10), - (_) => controller.add([value++]), - ); - await Future.delayed(const Duration(milliseconds: 520)); - timer.cancel(); - - // Five 100ms windows over ~500ms. A leading emission on top of the - // trailing one would roughly double this. - expect(received.length, inInclusiveRange(4, 6)); - - await subscription.cancel(); - await controller.close(); - }); + async.elapse(const Duration(milliseconds: 1)); + expect(received.last, [1, 2, 3, 4, 5]); + }); + }); - test('a larger collection is throttled for longer', () async { - final controller = StreamController>(); - final received = >[]; + // Completion is about ordering, not timing, so these run on the real event + // loop; `fakeAsync` does not turn this transformer's close path. + test('completes when the source closes while idle', () async { + final source = StreamController>(); + final received = >[]; + var done = false; + + source.stream + .throttleByCollectionSize( + interval: (_) => const Duration(milliseconds: 20), + ) + .listen(received.add, onDone: () => done = true); + + source.add([1]); + await Future.delayed(const Duration(milliseconds: 60)); + expect(received, [ + [1], + ]); + expect(done, isFalse); + + await source.close(); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(done, isTrue, reason: 'an idle throttle must still close'); + }); + + test('emits the held value when the source closes mid-window', () async { + final source = StreamController>(); + final received = >[]; + var done = false; + + source.stream + .throttleByCollectionSize( + interval: (_) => const Duration(seconds: 10), + ) + .listen(received.add, onDone: () => done = true); + + source.add([1]); + await source.close(); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(received, [ + [1], + ], reason: 'a single held value must not be swallowed on close'); + expect(done, isTrue); + }); + + test('emits the latest of several held values on close', () async { + final source = StreamController>(); + final received = >[]; + var done = false; + + source.stream + .throttleByCollectionSize( + interval: (_) => const Duration(seconds: 10), + ) + .listen(received.add, onDone: () => done = true); + + source + ..add([1]) + ..add([1, 2]); + await source.close(); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(received, [ + [1, 2], + ]); + expect(done, isTrue); + }); + + test('forwards errors', () { + fakeAsync((async) { + final source = StreamController>(); + final errors = []; + + final subscription = source.stream + .throttleByCollectionSize( + interval: (_) => const Duration(milliseconds: 50), + ) + .listen(null, onError: errors.add); - final subscription = controller.stream - .throttleByCollectionSize( - interval: (size) => Duration(milliseconds: size < 3 ? 20 : 300), - ) - .listen(received.add); - - // A small list opens a short window, so its value lands quickly. - controller.add([1]); - await Future.delayed(const Duration(milliseconds: 60)); - expect(received, [ - [1], - ]); - - // A large list opens a long window that holds everything behind it. - controller - ..add([1, 2, 3]) - ..add([1, 2, 3, 4]) - ..add([1, 2, 3, 4, 5]); - await Future.delayed(const Duration(milliseconds: 100)); - expect(received.length, 1); - - await Future.delayed(const Duration(milliseconds: 300)); - expect(received.last, [1, 2, 3, 4, 5]); - - await subscription.cancel(); - await controller.close(); + source.addError('boom'); + async.elapse(Duration.zero); + + expect(errors, ['boom']); + + subscription.cancel(); + source.close(); + async.elapse(Duration.zero); + }); + }); + + test('stops its window when the listener cancels', () { + fakeAsync((async) { + // ignore: close_sinks + final source = StreamController>(); + final received = >[]; + + final subscription = source.stream + .throttleByCollectionSize( + interval: (_) => const Duration(milliseconds: 50), + ) + .listen(received.add); + + source.add([1]); + subscription.cancel(); + async.elapse(const Duration(milliseconds: 100)); + + expect(received, isEmpty); + expect(async.pendingTimers, isEmpty); + }); + }); }); group('CallPreferences.participantsThrottleInterval', () { diff --git a/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart b/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart index f4035a997..d728cb933 100644 --- a/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart +++ b/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart @@ -57,7 +57,12 @@ class CallParticipantsBuilder extends StatelessWidget { return StreamBuilder>( stream: call.participantsStream, initialData: call.state.value.callParticipants, - builder: (context, snapshot) => builder(context, snapshot.data!), + // `StreamBuilder` builds an error snapshot with no data, so fall back to + // the current state rather than throwing a null check over the real error. + builder: (context, snapshot) => builder( + context, + snapshot.data ?? call.state.value.callParticipants, + ), ); } } diff --git a/packages/stream_video_flutter/test/src/call_participants/call_participants_sorting_mixin_test.dart b/packages/stream_video_flutter/test/src/call_participants/call_participants_sorting_mixin_test.dart new file mode 100644 index 000000000..7a9f2d51c --- /dev/null +++ b/packages/stream_video_flutter/test/src/call_participants/call_participants_sorting_mixin_test.dart @@ -0,0 +1,176 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +CallParticipantState _participant( + String userId, { + bool isSpeaking = false, +}) { + return CallParticipantState( + userId: userId, + roles: const [], + name: userId, + custom: const {}, + sessionId: '$userId-session', + trackIdPrefix: '$userId-prefix', + isSpeaking: isSpeaking, + ); +} + +/// Minimal host for the mixin: it has no call, it just records how often the +/// mixin asked to repaint. +class _Host extends StatefulWidget { + const _Host({super.key, this.filter, this.sort}); + + final Filter? filter; + final Sort? sort; + + @override + State<_Host> createState() => _HostState(); +} + +class _HostState extends State<_Host> with CallParticipantsSortingMixin { + int builds = 0; + + @override + Filter? get participantFilter => widget.filter; + + @override + Sort? get participantSort => widget.sort; + + @override + Widget build(BuildContext context) { + builds++; + return const SizedBox.shrink(); + } +} + +Future<_HostState> _pumpHost( + WidgetTester tester, { + Filter? filter, + Sort? sort, +}) async { + final key = GlobalKey<_HostState>(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: _Host(key: key, filter: filter, sort: sort), + ), + ); + return key.currentState!; +} + +void main() { + testWidgets('keeps arrival order and appends newcomers', (tester) async { + final host = await _pumpHost(tester); + + host.recalculateParticipants([_participant('a'), _participant('b')]); + await tester.pump(); + expect(host.sortedParticipants.map((it) => it.userId), ['a', 'b']); + + // A newcomer goes to the end even when the source lists it first. + host.recalculateParticipants([ + _participant('c'), + _participant('b'), + _participant('a'), + ]); + await tester.pump(); + expect(host.sortedParticipants.map((it) => it.userId), ['a', 'b', 'c']); + }); + + testWidgets('a participant leaving does not reorder the rest', ( + tester, + ) async { + final host = await _pumpHost(tester); + + host.recalculateParticipants([ + _participant('a'), + _participant('b'), + _participant('c'), + ]); + await tester.pump(); + + host.recalculateParticipants([_participant('c'), _participant('a')]); + await tester.pump(); + expect(host.sortedParticipants.map((it) => it.userId), ['a', 'c']); + }); + + testWidgets('the sort is stable across equal participants', (tester) async { + // Everyone compares equal, so the previous order has to survive. + final host = await _pumpHost(tester, sort: (_, __) => 0); + + host.recalculateParticipants([ + _participant('a'), + _participant('b'), + _participant('c'), + ]); + await tester.pump(); + + host.recalculateParticipants([ + _participant('c'), + _participant('b'), + _participant('a'), + ]); + await tester.pump(); + expect(host.sortedParticipants.map((it) => it.userId), ['a', 'b', 'c']); + }); + + testWidgets('the sort comparator wins over arrival order', (tester) async { + final host = await _pumpHost( + tester, + sort: (a, b) => a.userId.compareTo(b.userId), + ); + + host.recalculateParticipants([ + _participant('c'), + _participant('a'), + _participant('b'), + ]); + await tester.pump(); + expect(host.sortedParticipants.map((it) => it.userId), ['a', 'b', 'c']); + }); + + testWidgets('the filter is applied', (tester) async { + final host = await _pumpHost(tester, filter: (it) => it.isSpeaking); + + host.recalculateParticipants([ + _participant('a', isSpeaking: true), + _participant('b'), + ]); + await tester.pump(); + expect(host.sortedParticipants.map((it) => it.userId), ['a']); + }); + + testWidgets('does not repaint when the same instances come back', ( + tester, + ) async { + final host = await _pumpHost(tester); + final participants = [_participant('a'), _participant('b')]; + + host.recalculateParticipants(participants); + await tester.pump(); + final buildsAfterFirst = host.builds; + + host.recalculateParticipants([...participants]); + await tester.pump(); + + expect( + host.builds, + buildsAfterFirst, + reason: 'identical participants must not trigger setState', + ); + }); + + testWidgets('repaints when a participant instance changes', (tester) async { + final host = await _pumpHost(tester); + + host.recalculateParticipants([_participant('a')]); + await tester.pump(); + final buildsAfterFirst = host.builds; + + host.recalculateParticipants([_participant('a', isSpeaking: true)]); + await tester.pump(); + + expect(host.builds, greaterThan(buildsAfterFirst)); + }); +} From 9f30cadce54683dbb8083821dd41db54a788f103 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 16 Sep 2026 17:04:42 +0200 Subject: [PATCH 05/13] fix(llc): stop pin churn and seal the audio level history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sfuPinsUpdated` re-pinned every already-pinned participant with a fresh `DateTime.now()`. `pinnedAt` is what orders pinned participants in the `pinned` comparator, so a pins event reshuffled them; it also broke instance identity for all of them on every event. Keep the existing server pin and skip the write when nothing moved. `audioLevels` is handed out as an unmodifiable view. Identity is what the change guards rely on to tell whether a participant moved, and that only holds while nothing mutates a collection in place — the bug this branch already fixed once in `copyWithUpdatedAudioLevels`. The next one now throws instead of quietly freezing the UI. Co-Authored-By: Claude Opus 5 --- packages/stream_video/CHANGELOG.md | 9 ++- .../call/state/mixins/state_sfu_mixin.dart | 52 ++++++++----- .../src/models/call_participant_state.dart | 19 ++++- .../src/call/state/state_sfu_mixin_test.dart | 78 +++++++++++++++++++ 4 files changed, 132 insertions(+), 26 deletions(-) diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index 21fad16c2..83b0db2b1 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -1,14 +1,19 @@ ## Upcoming +### ✅ Added + +- Added `Call.participantsStream`, which emits the participant list at an interval that grows with the participant count. `CallState.callParticipants` is unchanged. +- Added `CallPreferences.participantsThrottleInterval` to override that interval, or set it to `null` to emit every update. + ### 🔄 Changed - SFU participant events no longer emit a new call state when they leave every participant unchanged. - `CallParticipantState.audioLevel` and `audioLevels` now hold at their last value while a participant is silent, instead of tracking every below-threshold reading. -- Added `Call.participantsStream`, which emits the participant list at an interval that grows with the participant count. `CallState.callParticipants` is unchanged. -- Added `CallPreferences.participantsThrottleInterval` to override that interval, or set it to `null` to emit every update. +- `CallParticipantState.audioLevels` is now unmodifiable. ### 🐞 Fixed +- Fixed server-pinned participants being reordered on every pins event, as their `pinnedAt` was refreshed each time. - Fixed `CallParticipantState.copyWithUpdatedAudioLevels` mutating the audio level history it shares with the previous state. ## 1.6.0 diff --git a/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart b/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart index 75b929631..b935a00ce 100644 --- a/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart +++ b/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart @@ -220,27 +220,39 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { for (final pin in pins) _participantKey(pin.userId, pin.sessionId), }; - state = state.copyWith( - callParticipants: state.callParticipants.map((participant) { - final isPinned = pinnedKeys.contains( - _participantKey(participant.userId, participant.sessionId), + var changed = false; + final participants = state.callParticipants.map((participant) { + final isPinned = pinnedKeys.contains( + _participantKey(participant.userId, participant.sessionId), + ); + final serverPin = participant.pin != null && !participant.pin!.isLocalPin; + + if (isPinned) { + // `pinnedAt` orders pinned participants, so an already-pinned one keeps + // the time it was pinned at rather than jumping to the front of the + // order on every pins event. + if (serverPin) return participant; + + changed = true; + return participant.copyWithPin( + participantPin: CallParticipantPin( + isLocalPin: false, + pinnedAt: DateTime.now(), + ), ); - if (isPinned) { - return participant.copyWithPin( - participantPin: CallParticipantPin( - isLocalPin: false, - pinnedAt: DateTime.now(), - ), - ); - } else if (participant.pin != null && !participant.pin!.isLocalPin) { - return participant.copyWithPin( - participantPin: null, - ); - } else { - return participant; - } - }).toList(), - ); + } + + if (serverPin) { + changed = true; + return participant.copyWithPin(participantPin: null); + } + + return participant; + }).toList(); + + if (!changed) return; + + state = state.copyWith(callParticipants: participants); } void sfuConnectionQualityChanged( diff --git a/packages/stream_video/lib/src/models/call_participant_state.dart b/packages/stream_video/lib/src/models/call_participant_state.dart index 3e3fe87bf..4e1ca97cb 100644 --- a/packages/stream_video/lib/src/models/call_participant_state.dart +++ b/packages/stream_video/lib/src/models/call_participant_state.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:equatable/equatable.dart'; import 'package:meta/meta.dart'; @@ -38,10 +40,10 @@ class CallParticipantState extends Equatable this.viewportVisibility = ViewportVisibility.unknown, this.screenShareViewportVisibility = ViewportVisibility.unknown, this.participantSource, - }) : audioLevels = audioLevels ?? [audioLevel]; + }) : audioLevels = _sealLevels(audioLevels ?? [audioLevel]); /// Internal constructor to be used with copyWith methods - const CallParticipantState._({ + CallParticipantState._({ required this.userId, required this.roles, required this.name, @@ -56,7 +58,7 @@ class CallParticipantState extends Equatable required this.connectionQuality, required this.isOnline, required this.audioLevel, - required this.audioLevels, + required List audioLevels, required this.isSpeaking, required this.isDominantSpeaker, required this.pin, @@ -64,7 +66,7 @@ class CallParticipantState extends Equatable required this.viewportVisibility, required this.screenShareViewportVisibility, required this.participantSource, - }); + }) : audioLevels = _sealLevels(audioLevels); final String userId; final List roles; @@ -103,6 +105,15 @@ class CallParticipantState extends Equatable final ViewportVisibility screenShareViewportVisibility; bool get isPinned => pin != null; + + /// Identity is used all over the SDK to tell whether a participant changed, + /// which only holds while nothing mutates a collection in place. Handing out + /// an unmodifiable view makes that an error rather than a silently stale UI. + static List _sealLevels(List levels) { + if (levels is UnmodifiableListView) return levels; + return UnmodifiableListView(levels); + } + String get uniqueParticipantKey => '$userId-$sessionId'; /// Returns a copy of this [CallParticipantState] with the given fields diff --git a/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart b/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart index 7ce782c1f..b570d0d01 100644 --- a/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart +++ b/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart @@ -4,6 +4,7 @@ import 'package:stream_video/src/sfu/data/events/sfu_events.dart'; import 'package:stream_video/src/sfu/data/models/sfu_audio_level.dart'; import 'package:stream_video/src/sfu/data/models/sfu_connection_info.dart'; import 'package:stream_video/src/sfu/data/models/sfu_inbound_video_state.dart'; +import 'package:stream_video/src/sfu/data/models/sfu_pin.dart'; import 'package:stream_video/stream_video.dart'; CallParticipantState _participant({ @@ -353,4 +354,81 @@ void main() { ); }); }); + + group('sfuPinsUpdated', () { + test('keeps the original pinnedAt for an already pinned participant', () { + final notifier = _notifier([_participant(userId: 'alice')]); + const pin = SfuPin(userId: 'alice', sessionId: 'alice-session'); + + notifier.sfuPinsUpdated([pin]); + final first = notifier.callState.callParticipants.single; + expect(first.pin, isNotNull); + + notifier.sfuPinsUpdated([pin]); + + expect( + notifier.callState.callParticipants.single.pin!.pinnedAt, + first.pin!.pinnedAt, + reason: 'pinnedAt orders pinned participants, so it must not move', + ); + }); + + test('does not emit when the pins are unchanged', () { + final notifier = _notifier([_participant(userId: 'alice')]); + const pin = SfuPin(userId: 'alice', sessionId: 'alice-session'); + + notifier.sfuPinsUpdated([pin]); + final before = notifier.callState.callParticipants; + + notifier.sfuPinsUpdated([pin]); + + expect(identical(notifier.callState.callParticipants, before), isTrue); + }); + + test('clears a server pin that is no longer sent', () { + final notifier = _notifier([_participant(userId: 'alice')]); + + notifier.sfuPinsUpdated([ + const SfuPin(userId: 'alice', sessionId: 'alice-session'), + ]); + expect(notifier.callState.callParticipants.single.pin, isNotNull); + + notifier.sfuPinsUpdated([]); + expect(notifier.callState.callParticipants.single.pin, isNull); + }); + }); + + group('audioLevels', () { + test('cannot be mutated through the participant', () { + final participant = _participant(userId: 'alice'); + + expect( + () => participant.audioLevels.add(0.5), + throwsUnsupportedError, + reason: 'identity checks rely on collections never changing in place', + ); + }); + + test('stays unmodifiable after an audio level update', () { + final notifier = _notifier([_participant(userId: 'alice')]); + + notifier.sfuUpdateAudioLevelChanged( + const SfuAudioLevelChangedEvent( + audioLevels: [ + SfuAudioLevel( + userId: 'alice', + sessionId: 'alice-session', + level: 0.8, + isSpeaking: true, + ), + ], + ), + ); + + expect( + () => notifier.callState.callParticipants.single.audioLevels.add(0.1), + throwsUnsupportedError, + ); + }); + }); } From 05b3f38264e0e06fd152da0a835e52870ae65f7c Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 16 Sep 2026 17:14:13 +0200 Subject: [PATCH 06/13] fix(ui): hold one participants subscription across rebuilds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CallParticipantsBuilder` was a `StatelessWidget` reading `call.participantsStream` in `build`. That getter hands out a fresh chain per access, each with its own throttle window, so `StreamBuilder` saw a new stream object on every rebuild and resubscribed — restarting the window each time. An ancestor rebuilding faster than the interval starved the list indefinitely: no blank frame, since the last snapshot is retained, it just stopped updating. At the >=100 tier the window is a second, so an animation or a rotation was enough, in exactly the large-livestream case this branch targets. Capture the stream in state instead, re-taking it only when the call changes. Regression introduced when `participantsStream` went from a `late final` to a getter; `StreamCallParticipants` and the Android PiP overlay already held their subscriptions and were unaffected. The new test fails on the old widget with 6 stream accesses across 5 rebuilds instead of 1. Co-Authored-By: Claude Opus 5 --- packages/stream_video_flutter/CHANGELOG.md | 2 +- .../widgets/partial_call_state_builder.dart | 33 +++- .../call_participants_builder_test.dart | 154 ++++++++++++++++++ 3 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 packages/stream_video_flutter/test/src/widgets/call_participants_builder_test.dart diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 9d7a1fb72..3b121df41 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -2,7 +2,7 @@ ### ✅ Added -- Added `CallParticipantsBuilder`, which builds from `Call.participantsStream` and seeds its first frame from the current call state. +- Added `CallParticipantsBuilder`, which builds from `Call.participantsStream`, holds one subscription across rebuilds, and seeds its first frame from the current call state. ### 🔄 Changed diff --git a/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart b/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart index d728cb933..1d86cad17 100644 --- a/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart +++ b/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart @@ -38,7 +38,7 @@ class PartialCallStateBuilder extends StatelessWidget { /// derived value is needed — a count, whether anyone is speaking — select that /// through [PartialCallStateBuilder] instead, so the widget doesn't rebuild on /// participant updates that leave it the same. -class CallParticipantsBuilder extends StatelessWidget { +class CallParticipantsBuilder extends StatefulWidget { const CallParticipantsBuilder({ required this.call, required this.builder, @@ -52,16 +52,39 @@ class CallParticipantsBuilder extends StatelessWidget { ) builder; + @override + State createState() => + _CallParticipantsBuilderState(); +} + +class _CallParticipantsBuilderState extends State { + // `Call.participantsStream` hands out a new stream per access, and each one + // starts its own throttle window. Reading it in `build` would make + // `StreamBuilder` resubscribe on every rebuild, so an ancestor rebuilding + // faster than the interval would restart the window forever and the list + // would stop updating. + late Stream> _participants = + widget.call.participantsStream; + + @override + void didUpdateWidget(covariant CallParticipantsBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.call != oldWidget.call) { + _participants = widget.call.participantsStream; + } + } + @override Widget build(BuildContext context) { return StreamBuilder>( - stream: call.participantsStream, - initialData: call.state.value.callParticipants, + stream: _participants, + initialData: widget.call.state.value.callParticipants, // `StreamBuilder` builds an error snapshot with no data, so fall back to // the current state rather than throwing a null check over the real error. - builder: (context, snapshot) => builder( + builder: (context, snapshot) => widget.builder( context, - snapshot.data ?? call.state.value.callParticipants, + snapshot.data ?? widget.call.state.value.callParticipants, ), ); } diff --git a/packages/stream_video_flutter/test/src/widgets/call_participants_builder_test.dart b/packages/stream_video_flutter/test/src/widgets/call_participants_builder_test.dart new file mode 100644 index 000000000..ee5996f9e --- /dev/null +++ b/packages/stream_video_flutter/test/src/widgets/call_participants_builder_test.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../mocks.dart'; + +CallParticipantState _participant(String userId) { + return CallParticipantState( + userId: userId, + roles: const [], + name: userId, + custom: const {}, + sessionId: '$userId-session', + trackIdPrefix: '$userId-prefix', + ); +} + +void main() { + late MockCall call; + late MockCallState callState; + late MockStateEmitter stateEmitter; + late StreamController> participants; + late int streamAccesses; + + setUp(() { + call = MockCall(); + callState = MockCallState(); + stateEmitter = MockStateEmitter(); + participants = StreamController>.broadcast(); + streamAccesses = 0; + + when(() => callState.callParticipants).thenReturn(const []); + when(() => stateEmitter.value).thenReturn(callState); + when(() => call.state).thenReturn(stateEmitter); + // `Call.participantsStream` is a getter that builds a fresh chain each + // time, so count how often the widget reaches for it. + when(() => call.participantsStream).thenAnswer((_) { + streamAccesses++; + return participants.stream; + }); + }); + + tearDown(() => participants.close()); + + testWidgets('takes its stream once, not on every rebuild', (tester) async { + late StateSetter rebuildParent; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (context, setState) { + rebuildParent = setState; + return CallParticipantsBuilder( + call: call, + builder: (context, _) => const SizedBox.shrink(), + ); + }, + ), + ), + ); + + expect(streamAccesses, 1); + + for (var i = 0; i < 5; i++) { + rebuildParent(() {}); + await tester.pump(); + } + + expect( + streamAccesses, + 1, + reason: 'resubscribing would restart the throttle window each rebuild', + ); + }); + + testWidgets('keeps delivering updates while an ancestor rebuilds', ( + tester, + ) async { + late StateSetter rebuildParent; + List? rendered; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (context, setState) { + rebuildParent = setState; + return CallParticipantsBuilder( + call: call, + builder: (context, value) { + rendered = value; + return const SizedBox.shrink(); + }, + ); + }, + ), + ), + ); + + expect(rendered, isEmpty, reason: 'seeded from the current call state'); + + for (var i = 1; i <= 3; i++) { + participants.add([for (var n = 0; n < i; n++) _participant('u$n')]); + await tester.pump(); + rebuildParent(() {}); + await tester.pump(); + } + + expect( + rendered, + hasLength(3), + reason: 'a rebuilding ancestor must not starve the list', + ); + }); + + testWidgets('takes a new stream when the call changes', (tester) async { + final otherCall = MockCall(); + final otherParticipants = + StreamController>.broadcast(); + addTearDown(otherParticipants.close); + + when(() => otherCall.state).thenReturn(stateEmitter); + when( + () => otherCall.participantsStream, + ).thenAnswer((_) => otherParticipants.stream); + + late StateSetter setCall; + var useOther = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (context, setState) { + setCall = setState; + return CallParticipantsBuilder( + call: useOther ? otherCall : call, + builder: (context, _) => const SizedBox.shrink(), + ); + }, + ), + ), + ); + + setCall(() => useOther = true); + await tester.pump(); + + verify(() => otherCall.participantsStream).called(1); + }); +} From 8bd7dc8b8ea43d95c97c95f3271e8c56f4e43a5e Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 16 Sep 2026 17:20:33 +0200 Subject: [PATCH 07/13] refactor(llc): share one participants window across listeners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `participantsStream` was a getter building a fresh chain per listener. That was my own substitution for the review's suggestion, not the review's, and it traded away a property neither native SDK gives up: Swift has one `CollectionDelayedUpdateObserver` feeding `Call.state.participantsMap` and Android one `StateFlow`, so every consumer sees the same list in the same frame. Per-listener windows open at different moments, so two widgets rendering one call could show different lists. It was also more work, not less: the upstream `partialState` map and `ListEquality` distinct ran once per listener per state emission instead of once. And a getter cannot have stable identity, which is what let `CallParticipantsBuilder` resubscribe on every rebuild. Back to one shared window, behind a `BehaviorSubject` rather than `asBroadcastStream()`. That covers what the review actually objected to: the subject carries the latest value, so a late listener does not wait for the list to change. `shareValue()` was the review's suggestion and is still not usable here — it is refcounted, so it would resubscribe to the transformer's single-subscription controller after the last listener left. The subject is exposed through a `late final` field, not `subject.stream`, which builds a new `_SubjectStream` on every access and would reintroduce the resubscribe bug. It lives as long as the state it reads from; `_stateManager.dispose()` is never called anywhere in `lib/src`. Co-Authored-By: Claude Opus 5 --- packages/stream_video/CHANGELOG.md | 2 +- packages/stream_video/lib/src/call/call.dart | 47 ++++++++++---- .../call/call_participants_stream_test.dart | 63 +++++++++++++++++++ 3 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 packages/stream_video/test/src/call/call_participants_stream_test.dart diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index 83b0db2b1..151eb8aa0 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -2,7 +2,7 @@ ### ✅ Added -- Added `Call.participantsStream`, which emits the participant list at an interval that grows with the participant count. `CallState.callParticipants` is unchanged. +- Added `Call.participantsStream`, which emits the participant list at an interval that grows with the participant count. One window is shared by every listener, and it carries the latest value. `CallState.callParticipants` is unchanged. - Added `CallPreferences.participantsThrottleInterval` to override that interval, or set it to `null` to emit every update. ### 🔄 Changed diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index 0925fd414..991624bce 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -8,6 +8,7 @@ import 'package:async/async.dart' show CancelableOperation; import 'package:collection/collection.dart'; import 'package:internet_connection_checker_plus/internet_connection_checker_plus.dart'; import 'package:meta/meta.dart'; +import 'package:rxdart/rxdart.dart'; import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' as rtc; import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; import 'package:synchronized/synchronized.dart'; @@ -442,22 +443,46 @@ class Call { /// stays immediate, so a lookup that has to see a participant the moment /// they join keeps working. /// - /// Each window emits the most recent list to arrive during it, so a - /// listener's first value is delayed by up to one interval. Read - /// [CallState.callParticipants] for a value to render before then. + /// One window is shared by every listener, so two widgets rendering the same + /// call always show the same list. It carries the latest value, which a new + /// listener receives on subscribing. /// - /// Every listener gets its own window, and it is torn down with that - /// listener's subscription. The interval comes from - /// [CallPreferences.participantsThrottleInterval], read each time this getter - /// is called, so a later `updateCallPreferences` reaches new listeners; set - /// it to null to emit every update. - Stream> get participantsStream { + /// The interval comes from [CallPreferences.participantsThrottleInterval], + /// read the first time this is used; set it to null to emit every update. + late final Stream> participantsStream = + _participantsSubject.stream; + + // Closed when the state it reads from closes; nothing else owns it. + // ignore: close_sinks + late final BehaviorSubject> _participantsSubject = + _buildParticipantsSubject(); + + BehaviorSubject> _buildParticipantsSubject() { + final subject = BehaviorSubject>.seeded( + _stateManager.callState.callParticipants, + ); + final participants = partialState((state) => state.callParticipants); final interval = _stateManager.callState.preferences.participantsThrottleInterval; - if (interval == null) return participants; - return participants.throttleByCollectionSize(interval: interval); + // Kept for the lifetime of the call, like the state it reads from. + // ignore: cancel_subscriptions + (interval == null + ? participants + : participants.throttleByCollectionSize(interval: interval)) + .listen( + (value) { + // The seed and the state's own replay are the same list, so the + // first window would otherwise repeat it. + if (identical(subject.valueOrNull, value)) return; + subject.add(value); + }, + onError: subject.addError, + onDone: subject.close, + ); + + return subject; } SharedEmitter< diff --git a/packages/stream_video/test/src/call/call_participants_stream_test.dart b/packages/stream_video/test/src/call/call_participants_stream_test.dart new file mode 100644 index 000000000..b2e63f03e --- /dev/null +++ b/packages/stream_video/test/src/call/call_participants_stream_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'fixtures/call_test_helpers.dart'; + +void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + registerMockFallbackValues(); + }); + + group('Call.participantsStream', () { + test('hands every listener the same stream', () { + final call = createTestCall(); + + expect( + identical(call.participantsStream, call.participantsStream), + isTrue, + reason: 'a fresh stream per access resubscribes on every rebuild', + ); + }); + + test('replays the latest value to a late listener', () async { + final call = createTestCall(); + + final first = []; + final subscription = call.participantsStream.listen( + (value) => first.add(value.length), + ); + await Future.delayed(const Duration(milliseconds: 50)); + expect(first, isNotEmpty); + + final joinedLate = []; + final lateSubscription = call.participantsStream.listen( + (value) => joinedLate.add(value.length), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + expect( + joinedLate, + isNotEmpty, + reason: 'a late listener must not wait for the list to change', + ); + + await subscription.cancel(); + await lateSubscription.cancel(); + }); + + test('gives two listeners the same values', () async { + final call = createTestCall(); + + final a = []; + final b = []; + final subA = call.participantsStream.listen((v) => a.add(v.length)); + final subB = call.participantsStream.listen((v) => b.add(v.length)); + + await Future.delayed(const Duration(milliseconds: 80)); + + expect(a, b, reason: 'one shared window, so no listener drifts'); + + await subA.cancel(); + await subB.cancel(); + }); + }); +} From a56d8fc573aa192cefca5ec627ee8a2e48ecf9d9 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 16 Sep 2026 17:27:06 +0200 Subject: [PATCH 08/13] docs(ui): correct the participants builder comment It described `Call.participantsStream` as handing out a new stream per access with a window each. Both stopped being true when the stream became one shared subject behind a `late final` field. Co-Authored-By: Claude Opus 5 --- .../lib/src/widgets/partial_call_state_builder.dart | 7 ++----- .../test/src/widgets/call_participants_builder_test.dart | 6 +++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart b/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart index 1d86cad17..4e1e0028d 100644 --- a/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart +++ b/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart @@ -58,11 +58,8 @@ class CallParticipantsBuilder extends StatefulWidget { } class _CallParticipantsBuilderState extends State { - // `Call.participantsStream` hands out a new stream per access, and each one - // starts its own throttle window. Reading it in `build` would make - // `StreamBuilder` resubscribe on every rebuild, so an ancestor rebuilding - // faster than the interval would restart the window forever and the list - // would stop updating. + // Held in state so one subscription spans every rebuild, and re-taken only + // when the call changes. late Stream> _participants = widget.call.participantsStream; diff --git a/packages/stream_video_flutter/test/src/widgets/call_participants_builder_test.dart b/packages/stream_video_flutter/test/src/widgets/call_participants_builder_test.dart index ee5996f9e..75445193c 100644 --- a/packages/stream_video_flutter/test/src/widgets/call_participants_builder_test.dart +++ b/packages/stream_video_flutter/test/src/widgets/call_participants_builder_test.dart @@ -35,8 +35,8 @@ void main() { when(() => callState.callParticipants).thenReturn(const []); when(() => stateEmitter.value).thenReturn(callState); when(() => call.state).thenReturn(stateEmitter); - // `Call.participantsStream` is a getter that builds a fresh chain each - // time, so count how often the widget reaches for it. + // Count how often the widget reaches for the stream: it must take one and + // keep it, whatever `Call.participantsStream` does internally. when(() => call.participantsStream).thenAnswer((_) { streamAccesses++; return participants.stream; @@ -73,7 +73,7 @@ void main() { expect( streamAccesses, 1, - reason: 'resubscribing would restart the throttle window each rebuild', + reason: 'a subscription must span rebuilds, not restart on each one', ); }); From 8f31907f5fd87d60b65912a04a074de670ca6482 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 18 Sep 2026 11:06:10 +0200 Subject: [PATCH 09/13] fix(llc,ui): address review findings on participant throttling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A listener subscribing mid-window was handed the list the last window closed on, which is older than the state it seeded from — a just-joined participant dropped off screen for up to an interval. Each listener now starts from the live participant list and the stale replay is dropped. - `_sealLevels` copies instead of wrapping, so a caller keeping the list it passed in cannot write through it. - A `participantsThrottleIntervalResolver` that throws surfaces as a stream error instead of stalling the stream with no window armed; a negative interval is treated as zero. - `CallPreferences.participantsThrottleInterval` is renamed to `participantsThrottleIntervalResolver`, matching `encryptionKeyResolver`. - `PartialCallStateBuilder` falls back to the current state on an error snapshot rather than failing a cast over the real error. - Breaking changes moved under their own changelog heading, and the docs on the preference, the audio level fields and the throttle corrected. Adds the missing guard tests: a speaker falling silent, a local pin surviving a server pins event, an unspecified quality not downgrading a known one, a no-op event pushing no call state, and the throttle collapsing and bypass paths. Co-Authored-By: Claude Opus 5 --- packages/stream_video/CHANGELOG.md | 16 +- packages/stream_video/lib/src/call/call.dart | 61 ++++++-- .../call/state/mixins/state_sfu_mixin.dart | 25 ++- .../src/models/call_participant_state.dart | 17 +- .../lib/src/models/call_preferences.dart | 21 +-- .../lib/src/models/participants_throttle.dart | 8 +- .../lib/src/utils/adaptive_throttle.dart | 31 ++-- .../call/call_participants_stream_test.dart | 123 +++++++++++++++ .../src/call/state/state_sfu_mixin_test.dart | 146 ++++++++++++++++++ .../src/utils/adaptive_throttle_test.dart | 65 +++++++- packages/stream_video_flutter/CHANGELOG.md | 8 +- .../call_participants/call_participants.dart | 6 +- .../call_participants_sorting_mixin.dart | 7 +- .../widgets/partial_call_state_builder.dart | 51 ++++-- .../call_participants_builder_test.dart | 32 ++++ 15 files changed, 543 insertions(+), 74 deletions(-) diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index 151eb8aa0..8c77a2427 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -1,20 +1,24 @@ ## Upcoming +### ⚠️ Breaking + +- `CallPreferences` now requires a `participantsThrottleIntervalResolver`; custom implementations must provide it. +- `CallParticipantState.audioLevels` is now unmodifiable. + ### ✅ Added -- Added `Call.participantsStream`, which emits the participant list at an interval that grows with the participant count. One window is shared by every listener, and it carries the latest value. `CallState.callParticipants` is unchanged. -- Added `CallPreferences.participantsThrottleInterval` to override that interval, or set it to `null` to emit every update. +- Added `Call.participantsStream`, which emits the participant list at an interval that grows with the participant count. +- Added `CallPreferences.participantsThrottleIntervalResolver` to override that interval, or set it to `null` to emit every change. ### 🔄 Changed - SFU participant events no longer emit a new call state when they leave every participant unchanged. -- `CallParticipantState.audioLevel` and `audioLevels` now hold at their last value while a participant is silent, instead of tracking every below-threshold reading. -- `CallParticipantState.audioLevels` is now unmodifiable. +- `CallParticipantState.audioLevel` and `audioLevels` now hold at their last value while a participant is silent. ### 🐞 Fixed -- Fixed server-pinned participants being reordered on every pins event, as their `pinnedAt` was refreshed each time. -- Fixed `CallParticipantState.copyWithUpdatedAudioLevels` mutating the audio level history it shares with the previous state. +- Fixed server-pinned participants being reordered on every pins event. +- Fixed `CallParticipantState.copyWithUpdatedAudioLevels` mutating the audio level history of the previous state. ## 1.6.0 diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index 991624bce..dede2c7c3 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -434,8 +434,9 @@ class Call { return _stateManager.partialCallStateStream(selector); } - /// The participants in this call, rate-limited at an interval that grows with - /// the participant count. + /// The participants in this call, rate-limited by + /// [CallPreferences.participantsThrottleIntervalResolver], which by default + /// returns a longer interval the more participants there are. /// /// Prefer this over `partialState((state) => state.callParticipants)` for /// anything that renders the list: in a large call the raw state emits far @@ -444,15 +445,53 @@ class Call { /// they join keeps working. /// /// One window is shared by every listener, so two widgets rendering the same - /// call always show the same list. It carries the latest value, which a new - /// listener receives on subscribing. + /// call always show the same list. A new listener starts from the current + /// [CallState.callParticipants] rather than from the last window, so it never + /// begins on a list older than the state it was built against. /// - /// The interval comes from [CallPreferences.participantsThrottleInterval], - /// read the first time this is used; set it to null to emit every update. + /// The interval comes from + /// [CallPreferences.participantsThrottleIntervalResolver], read the first + /// time this is used; set it to null to emit every change. late final Stream> participantsStream = - _participantsSubject.stream; + _buildParticipantsStream(); - // Closed when the state it reads from closes; nothing else owns it. + /// Each listener is given the live participant list first, then the shared + /// throttled ones. + /// + /// The subject replays the list the last window closed on, which can be older + /// than the state a listener is starting from — forwarding it would walk the + /// list backwards for up to one interval. That replay is dropped, and so is + /// any later value the listener has already been given. + Stream> _buildParticipantsStream() { + return Stream>.multi( + (controller) { + var latest = _stateManager.callState.callParticipants; + controller.add(latest); + + var replayed = false; + final subscription = _participantsSubject.stream.listen( + (value) { + // The subject always opens with its current value. + if (!replayed) { + replayed = true; + return; + } + if (identical(value, latest)) return; + latest = value; + controller.add(value); + }, + onError: controller.addError, + onDone: controller.close, + ); + + controller.onCancel = subscription.cancel; + }, + isBroadcast: true, + ); + } + + // Lives as long as this call: nothing closes `callStateStream` today, so the + // `onDone` below is a teardown path rather than one that runs in practice. // ignore: close_sinks late final BehaviorSubject> _participantsSubject = _buildParticipantsSubject(); @@ -463,8 +502,10 @@ class Call { ); final participants = partialState((state) => state.callParticipants); - final interval = - _stateManager.callState.preferences.participantsThrottleInterval; + final interval = _stateManager + .callState + .preferences + .participantsThrottleIntervalResolver; // Kept for the lifetime of the call, like the state it reads from. // ignore: cancel_subscriptions diff --git a/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart b/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart index b935a00ce..285648a56 100644 --- a/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart +++ b/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart @@ -11,8 +11,10 @@ import 'state_pending_tracks_mixin.dart'; final _logger = taggedLogger(tag: 'SV:CallState:Sfu'); -/// Identifies a participant the way the SFU does: a user can be in the same -/// call from several devices, so the session is part of the identity. +/// Keys a participant for lookup within one event. +/// +/// `userId` alone is not unique — a user can be in the call from several +/// devices — so the session is part of the key. String _participantKey(String userId, String sessionId) => '$userId:$sessionId'; mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { @@ -148,9 +150,10 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { participant.sessionId, )]; - // A participant who was silent and still is keeps their existing - // instance, so the list stays identical. Their `audioLevel` and - // `audioLevels` hold at the last value from while they were speaking. + // A participant the event does not mention, or who was silent and still + // is, keeps their existing instance. The event that takes them below the + // speaking threshold is still applied, so their `audioLevel` and + // `audioLevels` hold at that reading until they speak again. if (levelInfo == null || (!levelInfo.isSpeaking && !participant.isSpeaking)) { return participant; @@ -229,8 +232,7 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { if (isPinned) { // `pinnedAt` orders pinned participants, so an already-pinned one keeps - // the time it was pinned at rather than jumping to the front of the - // order on every pins event. + // the time it was pinned at. if (serverPin) return participant; changed = true; @@ -348,7 +350,14 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { it.userId == participant.userId && it.sessionId == participant.sessionId, ); - if (!isKnown) return; + if (!isKnown) { + _logger.w( + () => + '[sfuParticipantUpdated] dropped, unknown participant ' + '${participant.userId}/${participant.sessionId}', + ); + return; + } final participants = state.callParticipants.map((it) { if (it.userId == participant.userId && diff --git a/packages/stream_video/lib/src/models/call_participant_state.dart b/packages/stream_video/lib/src/models/call_participant_state.dart index 4e1ca97cb..288315069 100644 --- a/packages/stream_video/lib/src/models/call_participant_state.dart +++ b/packages/stream_video/lib/src/models/call_participant_state.dart @@ -83,10 +83,19 @@ class CallParticipantState extends Equatable final SfuParticipantSource? participantSource; final bool isOnline; - /// The latest audio level for the user. + /// The user's most recent audio level while they were above the speaking + /// threshold. + /// + /// Updates stop while a participant is silent, so this holds at the reading + /// that took them below the threshold rather than tracking every quiet + /// sample. Use [isSpeaking] to tell the two apart. final double audioLevel; - /// List of the last 10 audio levels. + /// The last 10 values [audioLevel] took, oldest first. + /// + /// Unmodifiable — a participant's identity is how the SDK detects change, so + /// mutating this in place would leave the UI stale rather than update it. + /// Build a new list instead. final List audioLevels; /// A list of tracks that are currently paused by our servers. @@ -111,7 +120,9 @@ class CallParticipantState extends Equatable /// an unmodifiable view makes that an error rather than a silently stale UI. static List _sealLevels(List levels) { if (levels is UnmodifiableListView) return levels; - return UnmodifiableListView(levels); + // Copied, not just wrapped: a view would still write through to whatever + // list the caller passed in and kept a reference to. + return UnmodifiableListView(List.of(levels)); } String get uniqueParticipantKey => '$userId-$sessionId'; diff --git a/packages/stream_video/lib/src/models/call_preferences.dart b/packages/stream_video/lib/src/models/call_preferences.dart index 3056b1494..709584442 100644 --- a/packages/stream_video/lib/src/models/call_preferences.dart +++ b/packages/stream_video/lib/src/models/call_preferences.dart @@ -69,13 +69,14 @@ abstract class CallPreferences { /// A large call emits participant updates far faster than a screen can /// usefully repaint, so the default returns a longer interval the more /// participants there are. Return a shorter one to trade CPU for latency, or - /// set this to `null` to emit every update. + /// set this to `null` to emit every change. /// - /// This does not affect `CallState.callParticipants`, which is always - /// up to date. It is read each time `Call.participantsStream` is used, so - /// changing it through `updateCallPreferences` reaches new listeners but - /// leaves existing ones on the interval they subscribed with. - ParticipantsThrottleInterval? get participantsThrottleInterval; + /// This does not affect `CallState.callParticipants`, which is always up to + /// date. It is read once, the first time `Call.participantsStream` is used; + /// changing it through `updateCallPreferences` after that has no effect for + /// that call. + ParticipantsThrottleIntervalResolver? + get participantsThrottleIntervalResolver; /// Supplies the shared key for this call when no `EncryptionManager` has been /// attached to it by hand. @@ -112,7 +113,8 @@ class DefaultCallPreferences implements CallPreferences { this.closedCaptionsVisibleCaptions = 2, this.videoModerationConfig = const VideoModerationConfig.disabled(), this.audioConfigurationPolicy, - this.participantsThrottleInterval = defaultParticipantsThrottleInterval, + this.participantsThrottleIntervalResolver = + defaultParticipantsThrottleInterval, this.encryptionKeyResolver, }); @@ -206,11 +208,12 @@ class DefaultCallPreferences implements CallPreferences { /// How long `Call.participantsStream` holds participant updates back, as a /// function of the participant count. See - /// [CallPreferences.participantsThrottleInterval]. + /// [CallPreferences.participantsThrottleIntervalResolver]. /// /// Defaults to [defaultParticipantsThrottleInterval]. @override - final ParticipantsThrottleInterval? participantsThrottleInterval; + final ParticipantsThrottleIntervalResolver? + participantsThrottleIntervalResolver; /// Supplies the shared key for this call when no manager was attached by /// hand. See [CallPreferences.encryptionKeyResolver]. diff --git a/packages/stream_video/lib/src/models/participants_throttle.dart b/packages/stream_video/lib/src/models/participants_throttle.dart index 7bd0ddc86..25985af7d 100644 --- a/packages/stream_video/lib/src/models/participants_throttle.dart +++ b/packages/stream_video/lib/src/models/participants_throttle.dart @@ -1,10 +1,12 @@ /// How long `Call.participantsStream` holds participant updates back, given /// how many participants the call currently has. /// -/// See `CallPreferences.participantsThrottleInterval`. -typedef ParticipantsThrottleInterval = Duration Function(int participantCount); +/// See `CallPreferences.participantsThrottleIntervalResolver`. +typedef ParticipantsThrottleIntervalResolver = + Duration Function(int participantCount); -/// The interval `Call.participantsStream` uses unless a call overrides it. +/// The interval `Call.participantsStream` uses unless a call overrides it: +/// roughly a frame below 16 participants, widening to a second at 100 or more. /// /// The tiers were taken from stream-video-swift's /// `CollectionDelayedUpdateObserver` (v1.52.0, September 2026). Nothing in this diff --git a/packages/stream_video/lib/src/utils/adaptive_throttle.dart b/packages/stream_video/lib/src/utils/adaptive_throttle.dart index e8bf772b1..711aed11d 100644 --- a/packages/stream_video/lib/src/utils/adaptive_throttle.dart +++ b/packages/stream_video/lib/src/utils/adaptive_throttle.dart @@ -7,15 +7,16 @@ extension AdaptiveCollectionThrottleX on Stream> { /// Rate-limits this stream to one value per window, where [interval] decides /// how long that window is from the size of the list that opened it. /// - /// The value emitted is the most recent one to arrive during the window, so - /// a change is never dropped — only collapsed with the ones around it. The - /// window opens on the first value after an idle period and that value is - /// held until it closes, which means a listener's first value is delayed by - /// up to one interval. + /// Intermediate values are collapsed: only the most recent value to arrive + /// during a window is emitted, so a listener never sees a stale list but does + /// not see every list that passed through either. The window opens on the + /// first value after an idle period and that value is held until it closes, + /// which means a listener's first value is delayed by one interval. /// /// [interval] is evaluated once per window, when it opens. A value arriving /// mid-window does not restart or re-measure it, so a change in list size - /// takes effect on the next window. + /// takes effect on the next window. A negative result is treated as zero, and + /// one that throws is forwarded as an error on this stream. /// /// When the source closes, anything still held is emitted before this stream /// closes, whether or not a window was open. @@ -26,9 +27,7 @@ extension AdaptiveCollectionThrottleX on Stream> { } } -/// Written by hand rather than with `rxdart`'s `throttle`, whose -/// `eventAfterLastWindow` strategy leaves the sink open when the source closes -/// with no window running, and drops a lone held value when it closes with one. +/// Emits at most one value per window, measured from the list that opened it. class _CollectionThrottle extends StreamTransformerBase, List> { const _CollectionThrottle(this._interval); @@ -51,7 +50,19 @@ class _CollectionThrottle extends StreamTransformerBase, List> { void onData(List value) { held = value; - window ??= Timer(_interval(value.length), () { + if (window != null) return; + + final Duration delay; + try { + delay = _interval(value.length); + } catch (e, stk) { + // Supplied by the integrator, so a throw here would otherwise reach the + // zone and leave this stream silently stalled with no window armed. + controller.addError(e, stk); + return; + } + + window = Timer(delay.isNegative ? Duration.zero : delay, () { window = null; emitHeld(); }); diff --git a/packages/stream_video/test/src/call/call_participants_stream_test.dart b/packages/stream_video/test/src/call/call_participants_stream_test.dart index b2e63f03e..79de20525 100644 --- a/packages/stream_video/test/src/call/call_participants_stream_test.dart +++ b/packages/stream_video/test/src/call/call_participants_stream_test.dart @@ -1,5 +1,40 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video/src/call/state/call_state_notifier.dart'; +import 'package:stream_video/stream_video.dart'; + import 'fixtures/call_test_helpers.dart'; +import 'fixtures/data.dart'; + +CallParticipantState _participant(String userId) { + return CallParticipantState( + userId: userId, + roles: const [], + name: userId, + custom: const {}, + sessionId: '$userId-session', + trackIdPrefix: '$userId-prefix', + ); +} + +CallStateNotifier _stateManager( + ParticipantsThrottleIntervalResolver? resolver, +) { + return CallStateNotifier( + CallState( + preferences: DefaultCallPreferences( + participantsThrottleIntervalResolver: resolver, + ), + currentUserId: SampleCallData.defaultUserInfo.id, + callCid: SampleCallData.defaultCid, + ), + ); +} + +void _setParticipants(CallStateNotifier manager, List userIds) { + manager.state = manager.state.copyWith( + callParticipants: userIds.map(_participant).toList(), + ); +} void main() { setUpAll(() { @@ -60,4 +95,92 @@ void main() { await subB.cancel(); }); }); + + group('Call.participantsStream throttling', () { + test('collapses several updates in one window into one emission', () async { + final manager = _stateManager((_) => const Duration(milliseconds: 150)); + final call = createTestCall(stateManager: manager); + + final seen = []; + final subscription = call.participantsStream.listen( + (value) => seen.add(value.length), + ); + await Future.delayed(const Duration(milliseconds: 20)); + seen.clear(); + + _setParticipants(manager, ['alice']); + _setParticipants(manager, ['alice', 'bob']); + _setParticipants(manager, ['alice', 'bob', 'carol']); + + await Future.delayed(const Duration(milliseconds: 300)); + + expect( + seen, + [3], + reason: 'one window, so only the last list of the three lands', + ); + + await subscription.cancel(); + }); + + test('a null resolver emits every change', () async { + final manager = _stateManager(null); + final call = createTestCall(stateManager: manager); + + final seen = []; + final subscription = call.participantsStream.listen( + (value) => seen.add(value.length), + ); + await Future.delayed(const Duration(milliseconds: 20)); + seen.clear(); + + _setParticipants(manager, ['alice']); + _setParticipants(manager, ['alice', 'bob']); + _setParticipants(manager, ['alice', 'bob', 'carol']); + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(seen, [1, 2, 3]); + + await subscription.cancel(); + }); + + test('a listener joining mid-window starts from the live state', () async { + final manager = _stateManager((_) => const Duration(milliseconds: 300)); + final call = createTestCall(stateManager: manager); + + final early = []; + final earlySubscription = call.participantsStream.listen( + (value) => early.add(value.length), + ); + await Future.delayed(const Duration(milliseconds: 20)); + + // Opens a window. Until it closes, the shared subject still carries the + // empty list this call started on. + _setParticipants(manager, ['alice', 'bob']); + await Future.delayed(const Duration(milliseconds: 20)); + + final late = []; + final lateSubscription = call.participantsStream.listen( + (value) => late.add(value.length), + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect( + late, + [2], + reason: + 'replaying the last closed window here would walk the list ' + 'backwards and drop both participants off screen', + ); + + await Future.delayed(const Duration(milliseconds: 300)); + + expect(late, [2], reason: 'and the window close must not repeat it'); + expect(early.last, 2); + + await earlySubscription.cancel(); + await lateSubscription.cancel(); + }); + }); } diff --git a/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart b/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart index b570d0d01..7c5d24fc1 100644 --- a/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart +++ b/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart @@ -431,4 +431,150 @@ void main() { ); }); }); + + group('guard regressions', () { + test('applies the event that takes a speaker below the threshold', () { + final notifier = _notifier([ + _participant(userId: 'alice', isSpeaking: true), + ]); + final before = notifier.callState.callParticipants; + + notifier.sfuUpdateAudioLevelChanged( + const SfuAudioLevelChangedEvent( + audioLevels: [ + SfuAudioLevel( + userId: 'alice', + sessionId: 'alice-session', + level: 0.05, + isSpeaking: false, + ), + ], + ), + ); + + final alice = notifier.callState.callParticipants.single; + expect( + alice.isSpeaking, + isFalse, + reason: + 'a speaker falling silent must still be written through, or ' + 'every speaking indicator latches on for the rest of the call', + ); + expect(alice.audioLevel, 0.05); + expect(identical(notifier.callState.callParticipants, before), isFalse); + }); + + test('keeps a local pin when the server sends its pins', () { + final notifier = _notifier([_participant(userId: 'alice')]); + notifier.setParticipantPinned( + sessionId: 'alice-session', + userId: 'alice', + pinned: true, + ); + final pinned = notifier.callState.callParticipants.single; + expect(pinned.pin!.isLocalPin, isTrue); + + notifier.sfuPinsUpdated([]); + + expect( + notifier.callState.callParticipants.single.pin, + isNotNull, + reason: "a pin the user placed is not the server's to clear", + ); + }); + + test('an unspecified quality does not downgrade a known one', () { + final notifier = _notifier([ + _participant( + userId: 'alice', + connectionQuality: SfuConnectionQuality.good, + ), + ]); + final before = notifier.callState.callParticipants; + + notifier.sfuConnectionQualityChanged( + const SfuConnectionQualityChangedEvent( + connectionQualityUpdates: [ + SfuConnectionQualityInfo( + userId: 'alice', + sessionId: 'alice-session', + connectionQuality: SfuConnectionQuality.unspecified, + ), + ], + ), + ); + + expect( + notifier.callState.callParticipants.single.connectionQuality, + SfuConnectionQuality.good, + ); + expect(identical(notifier.callState.callParticipants, before), isTrue); + }); + + test('a no-op event does not push a new call state', () async { + final notifier = _notifier([_participant(userId: 'alice')]); + + final seen = []; + final sub = notifier.callStateStream.valueStream.listen(seen.add); + await Future.delayed(Duration.zero); + seen.clear(); + + notifier.sfuUpdateAudioLevelChanged( + const SfuAudioLevelChangedEvent( + audioLevels: [ + SfuAudioLevel( + userId: 'alice', + sessionId: 'alice-session', + level: 0.2, + isSpeaking: false, + ), + ], + ), + ); + await Future.delayed(Duration.zero); + + expect( + seen, + isEmpty, + reason: + 'the call state is written unconditionally, so an identical ' + 'participant list is not enough to prove nothing was re-emitted', + ); + + await sub.cancel(); + }); + }); + + group('CallParticipantState.audioLevels', () { + test('keeps only the last 10 levels, newest last', () { + var participant = _participant(userId: 'alice'); + for (var i = 1; i <= 12; i++) { + participant = participant.copyWithUpdatedAudioLevels( + audioLevel: i / 100, + isSpeaking: true, + ); + } + + expect(participant.audioLevels, hasLength(10)); + expect(participant.audioLevels.last, 0.12); + expect(participant.audioLevels.first, 0.03); + }); + + test('cannot be mutated through the list handed to the constructor', () { + final levels = [0.1]; + final participant = _participant( + userId: 'alice', + ).copyWith(audioLevels: levels); + + levels.add(0.9); + + expect( + participant.audioLevels, + [0.1], + reason: + 'the state copies, so a caller keeping the list cannot write ' + 'through it and leave identity unchanged', + ); + }); + }); } diff --git a/packages/stream_video/test/src/utils/adaptive_throttle_test.dart b/packages/stream_video/test/src/utils/adaptive_throttle_test.dart index e71f8c29f..f594071af 100644 --- a/packages/stream_video/test/src/utils/adaptive_throttle_test.dart +++ b/packages/stream_video/test/src/utils/adaptive_throttle_test.dart @@ -268,10 +268,10 @@ void main() { }); }); - group('CallPreferences.participantsThrottleInterval', () { + group('CallPreferences.participantsThrottleIntervalResolver', () { test('defaults to the size-based tiers', () { expect( - DefaultCallPreferences().participantsThrottleInterval, + DefaultCallPreferences().participantsThrottleIntervalResolver, defaultParticipantsThrottleInterval, ); }); @@ -279,19 +279,70 @@ void main() { test('can be overridden with a fixed interval', () { const fixed = Duration(milliseconds: 100); final preferences = DefaultCallPreferences( - participantsThrottleInterval: (_) => fixed, + participantsThrottleIntervalResolver: (_) => fixed, ); - expect(preferences.participantsThrottleInterval!(1), fixed); - expect(preferences.participantsThrottleInterval!(500), fixed); + expect(preferences.participantsThrottleIntervalResolver!(1), fixed); + expect(preferences.participantsThrottleIntervalResolver!(500), fixed); }); test('can be turned off', () { final preferences = DefaultCallPreferences( - participantsThrottleInterval: null, + participantsThrottleIntervalResolver: null, ); - expect(preferences.participantsThrottleInterval, isNull); + expect(preferences.participantsThrottleIntervalResolver, isNull); + }); + }); + + group('a hostile interval callback', () { + test('surfaces a throw as a stream error instead of stalling', () { + fakeAsync((async) { + // ignore: close_sinks + final source = StreamController>(); + final received = >[]; + final errors = []; + + final subscription = source.stream + .throttleByCollectionSize( + interval: (size) => throw StateError('boom'), + ) + .listen(received.add, onError: errors.add); + + source.add([1]); + async.flushMicrotasks(); + async.elapse(const Duration(seconds: 2)); + + expect( + errors, + hasLength(1), + reason: + 'a throw reaching the zone would leave the stream silently ' + 'stalled with no window ever armed', + ); + expect(errors.single, isStateError); + expect(received, isEmpty); + + subscription.cancel(); + async.flushMicrotasks(); + }); + }); + + test('treats a negative interval as zero', () { + _throttled((_) => const Duration(milliseconds: -100), ( + async, + source, + received, + done, + ) { + source.add([1]); + async.flushMicrotasks(); + async.elapse(Duration.zero); + + expect(received, [ + [1], + ]); + }); }); }); } diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 3b121df41..aa5fc581d 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -2,13 +2,17 @@ ### ✅ Added -- Added `CallParticipantsBuilder`, which builds from `Call.participantsStream`, holds one subscription across rebuilds, and seeds its first frame from the current call state. +- Added `CallParticipantsBuilder`, which builds from `Call.participantsStream`. + +### 🐞 Fixed + +- Fixed `PartialCallStateBuilder` throwing a cast error instead of surfacing a partial state error. ### 🔄 Changed - Participant list widgets now subscribe to `Call.participantsStream`, which is throttled by participant count. - `StreamCallParticipants` and `StreamLivestreamHosts` no longer rebuild when an update leaves the rendered participants unchanged. -- `LivestreamContent` renders participants from `Call.participantsStream` instead of the raw call state. Call status is still read immediately. +- `LivestreamContent` renders participants from `Call.participantsStream` instead of the raw call state. - `LivestreamBackstageContent` only rebuilds when the participant count changes. ## 1.6.0 diff --git a/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart b/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart index 3e325b387..7732ca1d2 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart @@ -145,6 +145,7 @@ class _StreamCallParticipantsState extends State if (widget.participants != null) { _participantsSubscription?.cancel(); + _participantsSubscription = null; if (!const ListEquality().equals( widget.participants!.toList(), @@ -152,7 +153,10 @@ class _StreamCallParticipantsState extends State )) { recalculateParticipants(widget.participants!); } - } else if (widget.call != oldWidget.call) { + } else if (widget.call != oldWidget.call || + // Going back to the call's own list after a controlled one: the + // subscription was cancelled above and has to be re-taken. + _participantsSubscription == null) { _participantsSubscription?.cancel(); _participantsSubscription = widget.call.participantsStream.listen( recalculateParticipants, diff --git a/packages/stream_video_flutter/lib/src/call_participants/call_participants_sorting_mixin.dart b/packages/stream_video_flutter/lib/src/call_participants/call_participants_sorting_mixin.dart index 2d632a494..565a19d0c 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/call_participants_sorting_mixin.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/call_participants_sorting_mixin.dart @@ -76,9 +76,10 @@ mixin CallParticipantsSortingMixin on State { _sortedParticipantKeys = sortedKeys; - // The state layer hands back the same participant instance when an event - // leaves that participant untouched, so identity is enough to tell whether - // anything on screen would actually differ. + // The SFU state handlers hand back the same participant instance when an + // event leaves that participant untouched, so identical instances mean + // nothing on screen changed. Only sufficient, never necessary: a handler + // that stops preserving identity costs a repaint here, not correctness. final unchanged = identical(screenShareParticipant, _screenShareParticipant) && sortedParticipants.length == _participants.length && diff --git a/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart b/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart index 4e1e0028d..5243894c8 100644 --- a/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart +++ b/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:stream_video/stream_video.dart'; +final _logger = taggedLogger(tag: 'SV:PartialCallStateBuilder'); + /// Convenience widget to build a part of the call screen based on a partial call state. /// /// It wraps a [StreamBuilder] and uses the [call] and the [selector] to @@ -19,10 +21,24 @@ class PartialCallStateBuilder extends StatelessWidget { @override Widget build(BuildContext context) { - return StreamBuilder( + return StreamBuilder( stream: call.partialState(selector), initialData: selector(call.state.value), - builder: (context, snapshot) => builder(context, snapshot.data as T), + builder: (context, snapshot) { + if (snapshot.hasError) { + // An error snapshot carries no data, so fall back to the current + // state rather than letting the cast below fail over the real error. + _logger.e( + () => + '[PartialCallStateBuilder] partial state error: ' + '${snapshot.error}', + ); + return builder(context, selector(call.state.value)); + } + + // Not `??`: a selector whose `T` is nullable may legitimately hold null. + return builder(context, snapshot.data as T); + }, ); } } @@ -31,8 +47,8 @@ class PartialCallStateBuilder extends StatelessWidget { /// participants. /// /// Reads [Call.participantsStream], which is rate-limited by participant count, -/// and seeds the first frame from `call.state.value.callParticipants` so -/// nothing waits on the first throttle window. +/// and seeds the first frame from `call.state.value.callParticipants`, since +/// the stream delivers its first value asynchronously. /// /// Use this wherever the participants themselves get rendered. When only a /// derived value is needed — a count, whether anyone is speaking — select that @@ -58,8 +74,8 @@ class CallParticipantsBuilder extends StatefulWidget { } class _CallParticipantsBuilderState extends State { - // Held in state so one subscription spans every rebuild, and re-taken only - // when the call changes. + // Pins the stream identity across rebuilds, so `StreamBuilder` never tears + // down its subscription and restarts the throttle window. late Stream> _participants = widget.call.participantsStream; @@ -77,12 +93,23 @@ class _CallParticipantsBuilderState extends State { return StreamBuilder>( stream: _participants, initialData: widget.call.state.value.callParticipants, - // `StreamBuilder` builds an error snapshot with no data, so fall back to - // the current state rather than throwing a null check over the real error. - builder: (context, snapshot) => widget.builder( - context, - snapshot.data ?? widget.call.state.value.callParticipants, - ), + builder: (context, snapshot) { + if (snapshot.hasError) { + // `StreamBuilder` builds an error snapshot with no data, so fall back + // to the current state rather than throwing a null check over the + // real error — but don't let the error itself go unrecorded. + _logger.e( + () => + '[CallParticipantsBuilder] participantsStream error: ' + '${snapshot.error}', + ); + } + + return widget.builder( + context, + snapshot.data ?? widget.call.state.value.callParticipants, + ); + }, ); } } diff --git a/packages/stream_video_flutter/test/src/widgets/call_participants_builder_test.dart b/packages/stream_video_flutter/test/src/widgets/call_participants_builder_test.dart index 75445193c..85599dd4f 100644 --- a/packages/stream_video_flutter/test/src/widgets/call_participants_builder_test.dart +++ b/packages/stream_video_flutter/test/src/widgets/call_participants_builder_test.dart @@ -151,4 +151,36 @@ void main() { verify(() => otherCall.participantsStream).called(1); }); + + testWidgets('falls back to the current state when the stream errors', ( + tester, + ) async { + when(() => callState.callParticipants).thenReturn([_participant('alice')]); + + var built = []; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CallParticipantsBuilder( + call: call, + builder: (context, participants) { + built = participants; + return const SizedBox.shrink(); + }, + ), + ), + ); + + participants.addError('boom'); + await tester.pump(); + + expect( + built.map((it) => it.userId), + ['alice'], + reason: + 'an error snapshot carries no data, so the widget has to fall ' + 'back rather than throw a null check over the real error', + ); + expect(tester.takeException(), isNull); + }); } From 00f99e0a3590d73076da56f43993d9a6ea3debe7 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 18 Sep 2026 11:21:15 +0200 Subject: [PATCH 10/13] refactor(llc): derive the participant change guard from identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five guarded SFU handlers each carried a `changed` flag next to a `map` that returns the participant untouched when there is nothing to do. The flag says a second time what the returned instance already says, and a handler that forgets to set it drops the update silently. `_updateParticipants` takes the mapping function and decides from identity, so there is no flag to forget. It also builds no list at all when nothing changed, where each handler previously allocated one per event and threw it away — that path is every audio level tick. `sfuDominantSpeakerChanged` loses its flagged-set pre-check with it: comparing each participant's desired flag against the one they carry handles several flagged participants without reasoning about the case separately. Co-Authored-By: Claude Opus 5 --- .../call/state/mixins/state_sfu_mixin.dart | 110 +++++++----------- 1 file changed, 45 insertions(+), 65 deletions(-) diff --git a/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart b/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart index 285648a56..0f5016a42 100644 --- a/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart +++ b/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart @@ -18,6 +18,30 @@ final _logger = taggedLogger(tag: 'SV:CallState:Sfu'); String _participantKey(String userId, String sessionId) => '$userId:$sessionId'; mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { + /// Rewrites the participant list through [update], writing the state only if + /// some participant came back a different instance. + /// + /// [update] returns the participant it was given to mean "nothing to do". + void _updateParticipants( + CallParticipantState Function(CallParticipantState participant) update, + ) { + final participants = state.callParticipants; + List? updated; + + for (var index = 0; index < participants.length; index++) { + final participant = participants[index]; + final next = update(participant); + if (identical(next, participant)) continue; + + updated ??= [...participants]; + updated[index] = next; + } + + if (updated == null) return; + + state = state.copyWith(callParticipants: updated); + } + void sfuParticipantLeft( SfuParticipantLeftEvent event, ) { @@ -142,8 +166,7 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { _participantKey(level.userId, level.sessionId): level, }; - var changed = false; - final participants = state.callParticipants.map((participant) { + _updateParticipants((participant) { final levelInfo = levelsByParticipant[_participantKey( participant.userId, @@ -159,16 +182,11 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { return participant; } - changed = true; return participant.copyWithUpdatedAudioLevels( audioLevel: levelInfo.level, isSpeaking: levelInfo.isSpeaking, ); - }).toList(); - - if (!changed) return; - - state = state.copyWith(callParticipants: participants); + }); } void sfuDominantSpeakerChanged( @@ -178,37 +196,20 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { () => '[sfuDominantSpeakerChanged] ${state.sessionId}; event: $event', ); - // Nothing stops two participants carrying the flag — `sfuJoinResponse` and - // `sfuParticipantUpdated` both take it straight off the wire — so this only - // skips the pass when the event's participant is the sole one marked. - final flagged = state.callParticipants - .where((participant) => participant.isDominantSpeaker) - .toList(); - - if (flagged.length == 1 && - flagged.first.userId == event.userId && - flagged.first.sessionId == event.sessionId) { - return; - } + _updateParticipants((participant) { + // Every participant is checked, not just the one the event names: + // nothing stops two carrying the flag, since `sfuJoinResponse` and + // `sfuParticipantUpdated` both take it straight off the wire. + final isDominantSpeaker = + participant.userId == event.userId && + participant.sessionId == event.sessionId; - state = state.copyWith( - callParticipants: state.callParticipants.map((participant) { - // Mark the new dominant speaker - if (participant.userId == event.userId && - participant.sessionId == event.sessionId) { - return participant.copyWith( - isDominantSpeaker: true, - ); - } - // Unmark the old dominant speaker - if (participant.isDominantSpeaker) { - return participant.copyWith( - isDominantSpeaker: false, - ); - } + if (isDominantSpeaker == participant.isDominantSpeaker) { return participant; - }).toList(), - ); + } + + return participant.copyWith(isDominantSpeaker: isDominantSpeaker); + }); } /// Records whether the SFU considers this call end-to-end encrypted. @@ -223,8 +224,7 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { for (final pin in pins) _participantKey(pin.userId, pin.sessionId), }; - var changed = false; - final participants = state.callParticipants.map((participant) { + _updateParticipants((participant) { final isPinned = pinnedKeys.contains( _participantKey(participant.userId, participant.sessionId), ); @@ -235,7 +235,6 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { // the time it was pinned at. if (serverPin) return participant; - changed = true; return participant.copyWithPin( participantPin: CallParticipantPin( isLocalPin: false, @@ -244,17 +243,10 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { ); } - if (serverPin) { - changed = true; - return participant.copyWithPin(participantPin: null); - } + if (serverPin) return participant.copyWithPin(participantPin: null); return participant; - }).toList(); - - if (!changed) return; - - state = state.copyWith(callParticipants: participants); + }); } void sfuConnectionQualityChanged( @@ -267,8 +259,7 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { _participantKey(update.userId, update.sessionId): update, }; - var changed = false; - final participants = state.callParticipants.map((participant) { + _updateParticipants((participant) { final update = updatesByParticipant[_participantKey( participant.userId, @@ -281,13 +272,8 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { ); if (quality == participant.connectionQuality) return participant; - changed = true; return participant.copyWith(connectionQuality: quality); - }).toList(); - - if (!changed) return; - - state = state.copyWith(callParticipants: participants); + }); } void sfuParticipantJoined( @@ -406,8 +392,7 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { .add(inboundState); } - var changed = false; - final participants = state.callParticipants.map((participant) { + _updateParticipants((participant) { final inboundStates = statesByParticipant[_participantKey( participant.userId, @@ -434,14 +419,9 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { return participant; } - changed = true; return participant.copyWith( pausedTracks: pausedTracks, ); - }).toList(); - - if (!changed) return; - - state = state.copyWith(callParticipants: participants); + }); } } From f52c1890101eff02bddb25b2b78caadd76b3d88a Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 18 Sep 2026 12:05:29 +0200 Subject: [PATCH 11/13] fix(llc,ui): close three gaps found in review - `BehaviorSubject` caches its latest error as well as its latest value, so a listener could open on an error rather than a value. The flag that drops the subject's replay was only set by the value path, which then ate the next real participant list. - `_sealLevels` trusted an `UnmodifiableListView` it was handed. A view writes through to the list it was built over, so a caller could keep mutating what it hid. It always copies now. - `StreamBuilder` carries its snapshot across a stream swap, so `CallParticipantsBuilder` rendered the previous call's participants on the frame the call changed. Keyed on the call. Also corrects the first line of the `audioLevel` doc, which described the retained reading as one taken while the participant was above the speaking threshold when it is the one that took them below it. Co-Authored-By: Claude Opus 5 --- packages/stream_video/lib/src/call/call.dart | 8 ++- .../src/models/call_participant_state.dart | 12 ++-- .../call/call_participants_stream_test.dart | 43 +++++++++++++++ .../src/call/state/state_sfu_mixin_test.dart | 19 +++++++ .../widgets/partial_call_state_builder.dart | 4 ++ .../call_participants_builder_test.dart | 55 +++++++++++++++++++ 6 files changed, 132 insertions(+), 9 deletions(-) diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index dede2c7c3..1c1b0d32b 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -468,10 +468,11 @@ class Call { var latest = _stateManager.callState.callParticipants; controller.add(latest); + // The subject opens with whatever it currently holds, which is a value + // or — since it caches the latest error too — an error. var replayed = false; final subscription = _participantsSubject.stream.listen( (value) { - // The subject always opens with its current value. if (!replayed) { replayed = true; return; @@ -480,7 +481,10 @@ class Call { latest = value; controller.add(value); }, - onError: controller.addError, + onError: (Object error, StackTrace stackTrace) { + replayed = true; + controller.addError(error, stackTrace); + }, onDone: controller.close, ); diff --git a/packages/stream_video/lib/src/models/call_participant_state.dart b/packages/stream_video/lib/src/models/call_participant_state.dart index 288315069..c9af1c0c4 100644 --- a/packages/stream_video/lib/src/models/call_participant_state.dart +++ b/packages/stream_video/lib/src/models/call_participant_state.dart @@ -83,12 +83,11 @@ class CallParticipantState extends Equatable final SfuParticipantSource? participantSource; final bool isOnline; - /// The user's most recent audio level while they were above the speaking - /// threshold. + /// The most recent audio level retained for the user. /// /// Updates stop while a participant is silent, so this holds at the reading - /// that took them below the threshold rather than tracking every quiet - /// sample. Use [isSpeaking] to tell the two apart. + /// that took them below the speaking threshold rather than tracking every + /// quiet sample after it. Use [isSpeaking] to tell the two apart. final double audioLevel; /// The last 10 values [audioLevel] took, oldest first. @@ -119,9 +118,8 @@ class CallParticipantState extends Equatable /// which only holds while nothing mutates a collection in place. Handing out /// an unmodifiable view makes that an error rather than a silently stale UI. static List _sealLevels(List levels) { - if (levels is UnmodifiableListView) return levels; - // Copied, not just wrapped: a view would still write through to whatever - // list the caller passed in and kept a reference to. + // Always copied, never just wrapped: a view writes through to whatever list + // it was built over, including one an unmodifiable view already hides. return UnmodifiableListView(List.of(levels)); } diff --git a/packages/stream_video/test/src/call/call_participants_stream_test.dart b/packages/stream_video/test/src/call/call_participants_stream_test.dart index 79de20525..fc80c1501 100644 --- a/packages/stream_video/test/src/call/call_participants_stream_test.dart +++ b/packages/stream_video/test/src/call/call_participants_stream_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:stream_video/src/call/state/call_state_notifier.dart'; import 'package:stream_video/stream_video.dart'; @@ -182,5 +184,46 @@ void main() { await earlySubscription.cancel(); await lateSubscription.cancel(); }); + + test('delivers the next value after an error is replayed', () async { + final manager = _stateManager(null); + final call = createTestCall(stateManager: manager); + + final early = call.participantsStream.listen((_) {}, onError: (_) {}); + await Future.delayed(const Duration(milliseconds: 20)); + + // The emitter exposes a plain `Sink`, but it is a `BehaviorSubject` + // underneath, which is what caches the error for the next listener. + (manager.callStateStream.valueSink as EventSink).addError( + 'boom', + ); + await Future.delayed(const Duration(milliseconds: 20)); + + // A listener arriving now is opened with the cached error rather than a + // value, so the flag that drops the replay must be set by either. + final seen = []; + final errors = []; + final late = call.participantsStream.listen( + (value) => seen.add(value.length), + onError: errors.add, + ); + await Future.delayed(const Duration(milliseconds: 20)); + seen.clear(); + + _setParticipants(manager, ['alice']); + await Future.delayed(const Duration(milliseconds: 40)); + + expect( + seen, + [1], + reason: + 'the first value after a replayed error must not be eaten as ' + 'if it were the replay', + ); + expect(errors, isNotEmpty); + + await early.cancel(); + await late.cancel(); + }); }); } diff --git a/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart b/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart index 7c5d24fc1..2b7034afc 100644 --- a/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart +++ b/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:flutter_test/flutter_test.dart'; import 'package:stream_video/src/call/state/call_state_notifier.dart'; import 'package:stream_video/src/sfu/data/events/sfu_events.dart'; @@ -560,6 +562,23 @@ void main() { expect(participant.audioLevels.first, 0.03); }); + test('cannot be mutated through an unmodifiable view of a live list', () { + final backing = [0.1]; + final participant = _participant( + userId: 'alice', + ).copyWith(audioLevels: UnmodifiableListView(backing)); + + backing.add(0.9); + + expect( + participant.audioLevels, + [0.1], + reason: + 'an unmodifiable view still writes through to the list it was ' + 'built over, so it cannot be trusted as already sealed', + ); + }); + test('cannot be mutated through the list handed to the constructor', () { final levels = [0.1]; final participant = _participant( diff --git a/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart b/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart index 5243894c8..e70b5da7f 100644 --- a/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart +++ b/packages/stream_video_flutter/lib/src/widgets/partial_call_state_builder.dart @@ -91,6 +91,10 @@ class _CallParticipantsBuilderState extends State { @override Widget build(BuildContext context) { return StreamBuilder>( + // `StreamBuilder` carries its snapshot across a stream swap, so without + // this the previous call's participants render until the new stream + // emits. A new key rebuilds it against the new `initialData`. + key: ObjectKey(widget.call), stream: _participants, initialData: widget.call.state.value.callParticipants, builder: (context, snapshot) { diff --git a/packages/stream_video_flutter/test/src/widgets/call_participants_builder_test.dart b/packages/stream_video_flutter/test/src/widgets/call_participants_builder_test.dart index 85599dd4f..87569d429 100644 --- a/packages/stream_video_flutter/test/src/widgets/call_participants_builder_test.dart +++ b/packages/stream_video_flutter/test/src/widgets/call_participants_builder_test.dart @@ -183,4 +183,59 @@ void main() { ); expect(tester.takeException(), isNull); }); + + testWidgets('does not show the previous call on the frame the call changes', ( + tester, + ) async { + final otherCall = MockCall(); + final otherState = MockCallState(); + final otherEmitter = MockStateEmitter(); + final otherParticipants = + StreamController>.broadcast(); + addTearDown(otherParticipants.close); + + when(() => callState.callParticipants).thenReturn([_participant('alice')]); + when(() => otherState.callParticipants).thenReturn([_participant('bob')]); + when(() => otherEmitter.value).thenReturn(otherState); + when(() => otherCall.state).thenReturn(otherEmitter); + when( + () => otherCall.participantsStream, + ).thenAnswer((_) => otherParticipants.stream); + + late StateSetter setCall; + var useOther = false; + var built = []; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (context, setState) { + setCall = setState; + return CallParticipantsBuilder( + call: useOther ? otherCall : call, + builder: (context, participants) { + built = participants; + return const SizedBox.shrink(); + }, + ); + }, + ), + ), + ); + + expect(built.map((it) => it.userId), ['alice']); + + setCall(() => useOther = true); + await tester.pump(); + + expect( + built.map((it) => it.userId), + ['bob'], + reason: + '`StreamBuilder` carries its snapshot across a stream swap, so ' + 'without a new key the previous call renders until the new stream ' + 'emits', + ); + }); } From b9d4b457d8e75ce44e1d41cf6e54460008f54ae3 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 18 Sep 2026 13:24:51 +0200 Subject: [PATCH 12/13] test(ui): cover handing participants back to the call `StreamCallParticipants` cancels its subscription when a controlled `participants` list is supplied. Setting that list back to null left neither branch of `didUpdateWidget` running, so the widget kept rendering the controlled list and never took the subscription again. The fix was already in, without a test that fails without it. Co-Authored-By: Claude Opus 5 --- .../call_participants_subscription_test.dart | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 packages/stream_video_flutter/test/src/call_participants/call_participants_subscription_test.dart diff --git a/packages/stream_video_flutter/test/src/call_participants/call_participants_subscription_test.dart b/packages/stream_video_flutter/test/src/call_participants/call_participants_subscription_test.dart new file mode 100644 index 000000000..2aa3886f8 --- /dev/null +++ b/packages/stream_video_flutter/test/src/call_participants/call_participants_subscription_test.dart @@ -0,0 +1,90 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../../test_utils/test_wrapper.dart'; +import '../mocks.dart'; + +CallParticipantState _participant(String userId) { + return CallParticipantState( + userId: userId, + roles: const [], + name: userId, + custom: const {}, + sessionId: '$userId-session', + trackIdPrefix: '$userId-prefix', + ); +} + +void main() { + late MockCall call; + late MockCallState callState; + late MockStateEmitter stateEmitter; + late StreamController> participants; + + setUp(() { + call = MockCall(); + callState = MockCallState(); + stateEmitter = MockStateEmitter(); + participants = StreamController>.broadcast(); + + when(() => callState.callParticipants).thenReturn([_participant('alice')]); + when(() => stateEmitter.value).thenReturn(callState); + when(() => call.state).thenReturn(stateEmitter); + when(() => call.participantsStream).thenAnswer((_) => participants.stream); + }); + + tearDown(() => participants.close()); + + testWidgets('follows the call again after a controlled list is removed', ( + tester, + ) async { + late StateSetter setControlled; + List? controlled = [_participant('carol')]; + final rendered = []; + + await tester.pumpWidget( + TestWrapper( + child: StatefulBuilder( + builder: (context, setState) { + setControlled = setState; + return StreamCallParticipants( + call: call, + participants: controlled, + callParticipantBuilder: (context, _, participant) { + rendered.add(participant.userId); + return const SizedBox.shrink(); + }, + ); + }, + ), + ), + ); + + expect(rendered, ['carol'], reason: 'the controlled list wins while set'); + + // Handing control back to the call: the subscription was cancelled when + // the controlled list arrived, so it has to be taken again here. + rendered.clear(); + setControlled(() => controlled = null); + await tester.pump(); + + expect(rendered, ['alice'], reason: 'falls back to the current call state'); + + rendered.clear(); + participants.add([_participant('bob')]); + await tester.pump(); + await tester.pump(); + + expect( + rendered, + ['bob'], + reason: + 'without re-subscribing the list freezes silently on the last ' + 'state it had, and no participant update ever lands again', + ); + }); +} From 93c7f86983761c49e14ccbf4be6bfd6a441cf058 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 22 Sep 2026 10:46:29 +0200 Subject: [PATCH 13/13] fix(llc,ui): address review on the participant update path - Handle `participantsStream` errors in the three subscriptions that had no `onError`, where a throwing `participantsThrottleIntervalResolver` reached the zone uncaught once per event. - Re-run the sort in `StreamCallParticipants.didUpdateWidget` when `sort` or `filter` changes. The throttled stream is silent in a quiet call, so a new comparator would otherwise wait for the next join or speaker. - Skip re-copying `audioLevels` in `CallParticipantState` when the list is already sealed, which is every `copyWith` that leaves audio alone, and seal the audio update's own list by adoption instead of copying it twice. - Apply the hold-while-silent rule in `sfuParticipantUpdated` too, so both paths that write audio levels agree, and route it through `_updateParticipants` so an event that changes nothing writes no state. Co-Authored-By: Claude Opus 5 --- .../call/state/mixins/state_sfu_mixin.dart | 55 +++--- .../src/models/call_participant_state.dart | 41 ++++- .../src/call/state/state_sfu_mixin_test.dart | 167 ++++++++++++++++++ packages/stream_video_flutter/CHANGELOG.md | 2 + .../call_participants/call_participants.dart | 48 +++-- .../android_pip_overlay.dart | 11 ++ .../call_participants_subscription_test.dart | 129 ++++++++++++++ 7 files changed, 410 insertions(+), 43 deletions(-) diff --git a/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart b/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart index 0f5016a42..bd9352e50 100644 --- a/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart +++ b/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart @@ -345,34 +345,39 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { return; } - final participants = state.callParticipants.map((it) { - if (it.userId == participant.userId && - it.sessionId == participant.sessionId) { - return it - .copyWith( - name: participant.userName, - custom: participant.custom, - customData: participant.customData, - image: participant.userImage, - trackIdPrefix: participant.trackLookupPrefix, - isSpeaking: participant.isSpeaking, - isDominantSpeaker: participant.isDominantSpeaker, - connectionQuality: participant.connectionQuality - .mergeWithPrevious( - it.connectionQuality, - ), - roles: participant.roles, - ) - .copyWithUpdatedAudioLevels(audioLevel: participant.audioLevel); - } else { + _updateParticipants((it) { + if (it.userId != participant.userId || + it.sessionId != participant.sessionId) { return it; } + + final updated = it.copyWith( + name: participant.userName, + custom: participant.custom, + customData: participant.customData, + image: participant.userImage, + trackIdPrefix: participant.trackLookupPrefix, + isSpeaking: participant.isSpeaking, + isDominantSpeaker: participant.isDominantSpeaker, + connectionQuality: participant.connectionQuality.mergeWithPrevious( + it.connectionQuality, + ), + roles: participant.roles, + ); + + // Same rule as `sfuUpdateAudioLevelChanged`: a participant who was + // silent and still is keeps the reading that took them below the + // speaking threshold, so the two paths that write audio levels agree. + // Without this, a `ParticipantUpdated` would advance the levels this + // handler's counterpart deliberately holds. + if (!participant.isSpeaking && !it.isSpeaking) { + return updated == it ? it : updated; + } + + return updated.copyWithUpdatedAudioLevels( + audioLevel: participant.audioLevel, + ); }); - state = state.copyWith( - callParticipants: [ - ...participants, - ], - ); } void sfuInboundStateNotification(SfuInboundStateNotificationEvent event) { diff --git a/packages/stream_video/lib/src/models/call_participant_state.dart b/packages/stream_video/lib/src/models/call_participant_state.dart index c9af1c0c4..822c0c8d1 100644 --- a/packages/stream_video/lib/src/models/call_participant_state.dart +++ b/packages/stream_video/lib/src/models/call_participant_state.dart @@ -118,9 +118,17 @@ class CallParticipantState extends Equatable /// which only holds while nothing mutates a collection in place. Handing out /// an unmodifiable view makes that an error rather than a silently stale UI. static List _sealLevels(List levels) { - // Always copied, never just wrapped: a view writes through to whatever list - // it was built over, including one an unmodifiable view already hides. - return UnmodifiableListView(List.of(levels)); + // Already sealed here, over a list nothing outside can reach, so it is + // shared rather than copied again. This is the common case: every + // `copyWith` that leaves audio alone — a pin, a reaction, viewport + // visibility, connection quality — passes the current field straight back. + if (levels is _SealedLevels) return levels; + + // Anything else is copied, never just wrapped: a view writes through to + // whatever list it was built over, including one an unmodifiable view + // already hides. `_SealedLevels` is private, so a caller cannot smuggle a + // list it still holds past the check above. + return _SealedLevels(List.of(levels, growable: false)); } String get uniqueParticipantKey => '$userId-$sessionId'; @@ -185,14 +193,20 @@ class CallParticipantState extends Equatable required double audioLevel, bool? isSpeaking, }) { - final levels = [...audioLevels, audioLevel]; - if (levels.length > 10) { - levels.removeRange(0, levels.length - 10); - } + // Dropped from the front while building rather than with a `removeRange` + // afterwards, so the window costs one list instead of two. + final start = audioLevels.length >= 10 ? audioLevels.length - 9 : 0; + final levels = [ + for (var index = start; index < audioLevels.length; index++) + audioLevels[index], + audioLevel, + ]; return copyWith( audioLevel: audioLevel, - audioLevels: levels, + // Built here and never handed out, so it is sealed by adoption instead + // of copied a second time inside the constructor. + audioLevels: _SealedLevels(levels), isSpeaking: isSpeaking, ); } @@ -353,3 +367,14 @@ class CallParticipantState extends Equatable image: image, ); } + +/// A `CallParticipantState.audioLevels` list owned by [CallParticipantState]. +/// +/// Private on purpose: it is the proof that the backing list came from inside +/// that class and is unreachable from anywhere else, which is what lets the +/// seal skip the copy. A public marker — testing for [UnmodifiableListView] — +/// would let a caller pass a view over a list they still hold and keep writing +/// through the seal. +class _SealedLevels extends UnmodifiableListView { + _SealedLevels(super.source); +} diff --git a/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart b/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart index 2b7034afc..8d37e7490 100644 --- a/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart +++ b/packages/stream_video/test/src/call/state/state_sfu_mixin_test.dart @@ -6,6 +6,7 @@ import 'package:stream_video/src/sfu/data/events/sfu_events.dart'; import 'package:stream_video/src/sfu/data/models/sfu_audio_level.dart'; import 'package:stream_video/src/sfu/data/models/sfu_connection_info.dart'; import 'package:stream_video/src/sfu/data/models/sfu_inbound_video_state.dart'; +import 'package:stream_video/src/sfu/data/models/sfu_participant.dart'; import 'package:stream_video/src/sfu/data/models/sfu_pin.dart'; import 'package:stream_video/stream_video.dart'; @@ -28,6 +29,32 @@ CallParticipantState _participant({ ); } +SfuParticipant _sfuParticipant({ + required String userId, + bool isSpeaking = false, + double audioLevel = 0, + String? userName, + SfuConnectionQuality connectionQuality = SfuConnectionQuality.unspecified, +}) { + return SfuParticipant( + userId: userId, + userName: userName ?? userId, + userImage: '', + sessionId: '$userId-session', + custom: const {}, + customData: const {}, + publishedTracks: const [], + joinedAt: DateTime.utc(2026), + trackLookupPrefix: '$userId-prefix', + connectionQuality: connectionQuality, + isSpeaking: isSpeaking, + isDominantSpeaker: false, + audioLevel: audioLevel, + roles: const [], + participantSource: SfuParticipantSource.webrtc, + ); +} + CallStateNotifier _notifier(List participants) { final callState = CallState( callCid: StreamCallCid.from( @@ -547,6 +574,146 @@ void main() { }); }); + group('sfuParticipantUpdated', () { + test('holds the audio level of a participant who is still silent', () { + // `image` matches what the event carries, so the only thing this event + // could change is the audio level. + var alice = _participant( + userId: 'alice', + isSpeaking: true, + ).copyWith(image: ''); + alice = alice.copyWithUpdatedAudioLevels(audioLevel: 0.4); + // The reading that took her below the threshold. + alice = alice.copyWithUpdatedAudioLevels( + audioLevel: 0.05, + isSpeaking: false, + ); + + final notifier = _notifier([alice]); + final before = notifier.callState.callParticipants.single; + + notifier.sfuParticipantUpdated( + SfuParticipantUpdatedEvent( + callCid: notifier.callState.callCid.value, + participant: _sfuParticipant(userId: 'alice', audioLevel: 0.01), + ), + ); + + final after = notifier.callState.callParticipants.single; + expect( + after.audioLevel, + 0.05, + reason: + 'the hold-while-silent rule has to hold on both paths that write ' + 'audio levels, not just on sfuUpdateAudioLevelChanged', + ); + expect(after.audioLevels, before.audioLevels); + expect( + identical(after, before), + isTrue, + reason: 'an event that changes nothing keeps the instance', + ); + }); + + test('advances the audio level once the participant speaks again', () { + final notifier = _notifier([_participant(userId: 'alice')]); + + notifier.sfuParticipantUpdated( + SfuParticipantUpdatedEvent( + callCid: notifier.callState.callCid.value, + participant: _sfuParticipant( + userId: 'alice', + isSpeaking: true, + audioLevel: 0.7, + ), + ), + ); + + final after = notifier.callState.callParticipants.single; + expect(after.audioLevel, 0.7); + expect(after.audioLevels, [0.0, 0.7]); + expect(after.isSpeaking, isTrue); + }); + + test('still writes the participant through when a field changed', () { + final notifier = _notifier([_participant(userId: 'alice')]); + + notifier.sfuParticipantUpdated( + SfuParticipantUpdatedEvent( + callCid: notifier.callState.callCid.value, + participant: _sfuParticipant(userId: 'alice', userName: 'Alice B.'), + ), + ); + + expect(notifier.callState.callParticipants.single.name, 'Alice B.'); + }); + + test('leaves the other participants on their own instances', () { + final notifier = _notifier([ + _participant(userId: 'alice'), + _participant(userId: 'bob'), + ]); + final bobBefore = notifier.callState.callParticipants[1]; + + notifier.sfuParticipantUpdated( + SfuParticipantUpdatedEvent( + callCid: notifier.callState.callCid.value, + participant: _sfuParticipant(userId: 'alice', userName: 'Alice B.'), + ), + ); + + expect( + identical(notifier.callState.callParticipants[1], bobBefore), + isTrue, + ); + }); + }); + + group('CallParticipantState audio level sealing', () { + test('a copy that leaves audio alone shares the level list', () { + final alice = _participant( + userId: 'alice', + ).copyWithUpdatedAudioLevels(audioLevel: 0.4, isSpeaking: true); + + final repinned = alice.copyWith(isDominantSpeaker: true); + + expect( + identical(repinned.audioLevels, alice.audioLevels), + isTrue, + reason: + 'the list is already sealed over a list nothing outside can reach, ' + 'so re-copying it on every unrelated copyWith is pure waste', + ); + }); + + test('a level update does not share history with the previous copy', () { + final alice = _participant( + userId: 'alice', + ).copyWithUpdatedAudioLevels(audioLevel: 0.4, isSpeaking: true); + + final next = alice.copyWithUpdatedAudioLevels( + audioLevel: 0.6, + isSpeaking: true, + ); + + expect(alice.audioLevels, [0.0, 0.4]); + expect(next.audioLevels, [0.0, 0.4, 0.6]); + expect(identical(next.audioLevels, alice.audioLevels), isFalse); + }); + + test('a sealed list is still unmodifiable', () { + final alice = _participant( + userId: 'alice', + ).copyWithUpdatedAudioLevels(audioLevel: 0.4, isSpeaking: true); + + expect(() => alice.audioLevels.add(0.5), throwsUnsupportedError); + expect( + () => alice.copyWith(isLocal: true).audioLevels.add(0.5), + throwsUnsupportedError, + ); + }); + }); + group('CallParticipantState.audioLevels', () { test('keeps only the last 10 levels, newest last', () { var participant = _participant(userId: 'alice'); diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index aa5fc581d..c6bc953e7 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -7,6 +7,8 @@ ### 🐞 Fixed - Fixed `PartialCallStateBuilder` throwing a cast error instead of surfacing a partial state error. +- Fixed `StreamCallParticipants` not applying a changed `sort` or `filter` until the participant list changed. +- Fixed a `Call.participantsStream` error reaching the zone uncaught instead of being logged. ### 🔄 Changed diff --git a/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart b/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart index 7732ca1d2..6c1fdcd86 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/call_participants.dart @@ -9,6 +9,8 @@ import '../../stream_video_flutter.dart'; import 'regular_call_participants_content.dart'; import 'screen_share_call_participants_content.dart'; +final _logger = taggedLogger(tag: 'SV:CallParticipants'); + /// Builder function used to build a participant item. typedef CallParticipantBuilder = Widget Function( @@ -127,12 +129,29 @@ class _StreamCallParticipantsState extends State ); if (widget.participants == null) { - _participantsSubscription = widget.call.participantsStream.listen( - recalculateParticipants, - ); + _subscribeToParticipants(); } } + /// Subscribes to the call's own participant list. + /// + /// [Call.participantsStream] carries an error when a custom + /// [CallPreferences.participantsThrottleIntervalResolver] throws. Without an + /// `onError` that would go to the zone as an uncaught async error, once per + /// event, so it is logged here and the last known list stays on screen. + void _subscribeToParticipants() { + _participantsSubscription = widget.call.participantsStream.listen( + recalculateParticipants, + onError: (Object error, StackTrace stackTrace) { + _logger.e( + () => + '[StreamCallParticipants] participantsStream error: $error; ' + '$stackTrace', + ); + }, + ); + } + @override void dispose() { _participantsSubscription?.cancel(); @@ -143,14 +162,18 @@ class _StreamCallParticipantsState extends State void didUpdateWidget(covariant StreamCallParticipants oldWidget) { super.didUpdateWidget(oldWidget); + final orderingChanged = + widget.sort != oldWidget.sort || widget.filter != oldWidget.filter; + if (widget.participants != null) { _participantsSubscription?.cancel(); _participantsSubscription = null; - if (!const ListEquality().equals( - widget.participants!.toList(), - oldWidget.participants?.toList(), - )) { + if (orderingChanged || + !const ListEquality().equals( + widget.participants!.toList(), + oldWidget.participants?.toList(), + )) { recalculateParticipants(widget.participants!); } } else if (widget.call != oldWidget.call || @@ -158,10 +181,15 @@ class _StreamCallParticipantsState extends State // subscription was cancelled above and has to be re-taken. _participantsSubscription == null) { _participantsSubscription?.cancel(); - _participantsSubscription = widget.call.participantsStream.listen( - recalculateParticipants, - ); + _subscribeToParticipants(); + recalculateParticipants(widget.call.state.value.callParticipants); + } else if (orderingChanged) { + // Nothing re-sorts on its own: the stream only emits when the list + // changes, so in a quiet call a new comparator would otherwise wait for + // the next join or speaker. Sorting an unchanged list is cheap here — + // `recalculateParticipants` skips the `setState` when the result is the + // same, which is also what absorbs a `sort` closure built in `build`. recalculateParticipants(widget.call.state.value.callParticipants); } } diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart index 783e5ee17..dc87299e2 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_content/picture_in_picture/android_pip_overlay.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import '../../../../stream_video_flutter.dart'; import '../../../call_participants/screen_share_call_participants_content.dart'; +final _logger = taggedLogger(tag: 'SV:AndroidPipOverlay'); + /// A dedicated overlay widget for Android Picture-in-Picture mode. /// This widget creates a floating overlay that shows only the video content /// optimized for PiP viewing. @@ -55,8 +57,17 @@ class _AndroidPipOverlayState extends State super.initState(); recalculateParticipants(widget.call.state.value.callParticipants); + // A custom `participantsThrottleIntervalResolver` that throws surfaces as + // an error here; without `onError` it would reach the zone uncaught. _participantsSubscription = widget.call.participantsStream.listen( recalculateParticipants, + onError: (Object error, StackTrace stackTrace) { + _logger.e( + () => + '[AndroidPipOverlay] participantsStream error: $error; ' + '$stackTrace', + ); + }, ); } diff --git a/packages/stream_video_flutter/test/src/call_participants/call_participants_subscription_test.dart b/packages/stream_video_flutter/test/src/call_participants/call_participants_subscription_test.dart index 2aa3886f8..ce43427b3 100644 --- a/packages/stream_video_flutter/test/src/call_participants/call_participants_subscription_test.dart +++ b/packages/stream_video_flutter/test/src/call_participants/call_participants_subscription_test.dart @@ -87,4 +87,133 @@ void main() { 'state it had, and no participant update ever lands again', ); }); + + testWidgets('keeps the last list when participantsStream errors', ( + tester, + ) async { + final rendered = []; + + await tester.pumpWidget( + TestWrapper( + child: StreamCallParticipants( + call: call, + callParticipantBuilder: (context, _, participant) { + rendered.add(participant.userId); + return const SizedBox.shrink(); + }, + ), + ), + ); + + participants.add([_participant('bob')]); + await tester.pump(); + await tester.pump(); + rendered.clear(); + + // A custom `participantsThrottleIntervalResolver` that throws surfaces + // here. Without an `onError` this reaches the zone uncaught, which + // `testWidgets` reports as a failure — which is the point of the test. + participants.addError(StateError('resolver blew up')); + await tester.pump(); + await tester.pump(); + + expect( + find.byType(StreamCallParticipants), + findsOneWidget, + reason: 'the error is logged, not rethrown at the app', + ); + + rendered.clear(); + participants.add([_participant('carol')]); + await tester.pump(); + await tester.pump(); + + expect( + rendered, + ['carol'], + reason: 'the subscription survives the error and keeps delivering', + ); + }); + + testWidgets('re-sorts when the sort comparator changes', (tester) async { + late StateSetter setSort; + var descending = false; + final rendered = []; + + when( + () => callState.callParticipants, + ).thenReturn([_participant('alice'), _participant('bob')]); + + await tester.pumpWidget( + TestWrapper( + child: StatefulBuilder( + builder: (context, setState) { + setSort = setState; + return StreamCallParticipants( + call: call, + sort: descending + ? (a, b) => b.userId.compareTo(a.userId) + : (a, b) => a.userId.compareTo(b.userId), + callParticipantBuilder: (context, _, participant) { + rendered.add(participant.userId); + return const SizedBox.shrink(); + }, + ); + }, + ), + ), + ); + + expect(rendered, ['alice', 'bob']); + + // No participant update follows, which is the whole point: the throttled + // stream is silent in a quiet call, so nothing else would re-sort. + rendered.clear(); + setSort(() => descending = true); + await tester.pump(); + + expect( + rendered, + ['bob', 'alice'], + reason: + 'a new comparator has to be applied on the spot, not on the next ' + 'join or audio level change', + ); + }); + + testWidgets('re-filters when the filter changes', (tester) async { + late StateSetter setFilter; + var hideBob = false; + final rendered = []; + + when( + () => callState.callParticipants, + ).thenReturn([_participant('alice'), _participant('bob')]); + + await tester.pumpWidget( + TestWrapper( + child: StatefulBuilder( + builder: (context, setState) { + setFilter = setState; + return StreamCallParticipants( + call: call, + filter: hideBob ? (p) => p.userId != 'bob' : (_) => true, + callParticipantBuilder: (context, _, participant) { + rendered.add(participant.userId); + return const SizedBox.shrink(); + }, + ); + }, + ), + ), + ); + + expect(rendered, ['alice', 'bob']); + + rendered.clear(); + setFilter(() => hideBob = true); + await tester.pump(); + + expect(rendered, ['alice']); + }); }