From f8db52b3fcc15ecf5ba6d1e21ec0bd40340e79c8 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 23 Oct 2025 14:40:48 +0700 Subject: [PATCH 1/3] squash fixes --- lib/src/core/room.dart | 39 +++++++--- lib/src/participant/remote.dart | 122 +++++++++++++++++++++++--------- test/core/room_e2e_test.dart | 31 ++++++++ 3 files changed, 148 insertions(+), 44 deletions(-) diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index 66ec8906..a8cd1b74 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -38,6 +38,7 @@ import '../participant/participant.dart'; import '../participant/remote.dart'; import '../proto/livekit_models.pb.dart' as lk_models; import '../proto/livekit_rtc.pb.dart' as lk_rtc; +import '../publication/remote.dart'; import '../support/disposable.dart'; import '../support/platform.dart'; import '../support/region_url_provider.dart'; @@ -636,15 +637,21 @@ class Room extends DisposableChangeNotifier with EventsEmittable { return null; } - Future _getOrCreateRemoteParticipant(String identity, lk_models.ParticipantInfo? info) async { + Future _getOrCreateRemoteParticipant( + String identity, lk_models.ParticipantInfo? info) async { RemoteParticipant? participant = _remoteParticipants[identity]; if (participant != null) { if (info != null) { await participant.updateFromInfo(info); } - return participant; + // Return existing participant with no new publications + return ParticipantCreationResult( + participant: participant, + newPublications: const [], + ); } + ParticipantCreationResult result; if (info == null) { logger.warning('RemoteParticipant.info is null identity: $identity'); participant = RemoteParticipant( @@ -653,16 +660,20 @@ class Room extends DisposableChangeNotifier with EventsEmittable { identity: identity, name: '', ); + result = ParticipantCreationResult( + participant: participant, + newPublications: const [], + ); } else { - participant = await RemoteParticipant.createFromInfo( + result = await RemoteParticipant.createFromInfo( room: this, info: info, ); } - _remoteParticipants[identity] = participant; - _sidToIdentity[participant.sid] = identity; - return participant; + _remoteParticipants[result.participant.identity] = result.participant; + _sidToIdentity[result.participant.sid] = result.participant.identity; + return result; } Future _onParticipantUpdateEvent(List updates) async { @@ -689,14 +700,22 @@ class Room extends DisposableChangeNotifier with EventsEmittable { continue; } - final participant = await _getOrCreateRemoteParticipant(info.identity, info); + final result = await _getOrCreateRemoteParticipant(info.identity, info); if (isNew) { hasChanged = true; - // fire connected event - emitWhenConnected(ParticipantConnectedEvent(participant: participant)); + // Emit connected event + emitWhenConnected(ParticipantConnectedEvent(participant: result.participant)); + // Emit TrackPublishedEvent for each new track + for (final pub in result.newPublications) { + [events].emit(TrackPublishedEvent( + participant: result.participant, + publication: pub, + )); + } + _sidToIdentity[info.sid] = info.identity; } else { - final wasUpdated = await participant.updateFromInfo(info); + final wasUpdated = await result.participant.updateFromInfo(info); if (wasUpdated) { _sidToIdentity[info.sid] = info.identity; } diff --git a/lib/src/participant/remote.dart b/lib/src/participant/remote.dart index 93e075e5..f368a7ad 100644 --- a/lib/src/participant/remote.dart +++ b/lib/src/participant/remote.dart @@ -31,6 +31,28 @@ import '../track/remote/video.dart'; import '../types/other.dart'; import 'participant.dart'; +/// Result of creating a RemoteParticipant with all its initial data populated. +/// +/// This struct is returned by [RemoteParticipant.createFromInfo] and contains +/// the fully initialized participant along with the list of track publications +/// that were added during creation. The caller is responsible for emitting +/// events in the correct order. +@internal +class ParticipantCreationResult { + /// The fully initialized remote participant with all basic info and tracks populated. + final RemoteParticipant participant; + + /// List of new track publications that were added during participant creation. + /// The caller should emit [TrackPublishedEvent] for each of these after + /// emitting [ParticipantConnectedEvent]. + final List newPublications; + + const ParticipantCreationResult({ + required this.participant, + required this.newPublications, + }); +} + /// Represents other participant in the [Room]. class RemoteParticipant extends Participant { @internal @@ -46,18 +68,70 @@ class RemoteParticipant extends Participant { name: name, ); - static Future createFromInfo({ + /// Creates a fully initialized RemoteParticipant without emitting events. + /// + /// Populates the participant with all data from [info] including metadata, permissions, + /// and track publications. No events are emitted, allowing the caller to control event + /// timing and order. + /// + /// Returns [ParticipantCreationResult] with the participant and new track publications. + /// The caller should emit [ParticipantConnectedEvent] first, then [TrackPublishedEvent] + /// for each track, ensuring the participant is fully populated when connected event fires. + /// + /// @internal - Should only be called by [Room]. + @internal + static Future createFromInfo({ required Room room, required lk_models.ParticipantInfo info, }) async { final participant = RemoteParticipant( room: room, - sid: info.identity, + sid: info.sid, identity: info.identity, name: info.name, ); - await participant.updateFromInfo(info); - return participant; + // Update basic participant info (state, metadata, etc.) + await participant._updateBasicInfo(info); + // Add tracks to participant without emitting events + final newPubs = await participant._addTracks(info.tracks); + // Return result for caller to emit events in correct order + return ParticipantCreationResult( + participant: participant, + newPublications: newPubs, + ); + } + + Future _updateBasicInfo(lk_models.ParticipantInfo info) async { + // Only call superclass updateFromInfo to update basic participant state + await super.updateFromInfo(info); + } + + Future> _addTracks(List tracks) async { + final newPubs = []; + for (final trackInfo in tracks) { + final RemoteTrackPublication? pub = getTrackPublicationBySid(trackInfo.sid); + if (pub == null) { + final RemoteTrackPublication pub; + if (trackInfo.type == lk_models.TrackType.VIDEO) { + pub = RemoteTrackPublication( + participant: this, + info: trackInfo, + ); + } else if (trackInfo.type == lk_models.TrackType.AUDIO) { + pub = RemoteTrackPublication( + participant: this, + info: trackInfo, + ); + } else { + throw UnexpectedStateException('Unknown track type'); + } + newPubs.add(pub); + addTrackPublication(pub); + } else { + pub.updateFromInfo(trackInfo); + } + } + return newPubs; } /// A convenience property to get all video tracks. @@ -189,34 +263,16 @@ class RemoteParticipant extends Participant { //return false; } - // figuring out deltas between tracks - final newPubs = {}; + await _updateTracks(info.tracks); - for (final trackInfo in info.tracks) { - final RemoteTrackPublication? pub = getTrackPublicationBySid(trackInfo.sid); - if (pub == null) { - final RemoteTrackPublication pub; - if (trackInfo.type == lk_models.TrackType.VIDEO) { - pub = RemoteTrackPublication( - participant: this, - info: trackInfo, - ); - } else if (trackInfo.type == lk_models.TrackType.AUDIO) { - pub = RemoteTrackPublication( - participant: this, - info: trackInfo, - ); - } else { - throw UnexpectedStateException('Unknown track type'); - } - newPubs.add(pub); - addTrackPublication(pub); - } else { - pub.updateFromInfo(trackInfo); - } - } + return true; + } - // always emit events for new publications, Room will not forward them unless it's ready + Future _updateTracks(List tracks) async { + // Add new tracks + final newPubs = await _addTracks(tracks); + + // Emit events for new publications for (final pub in newPubs) { final event = TrackPublishedEvent( participant: this, @@ -227,14 +283,12 @@ class RemoteParticipant extends Participant { } } - // remove any published track that is not in the info - final validSids = info.tracks.map((e) => e.sid); + // Remove any published track that is not in the info + final validSids = tracks.map((e) => e.sid); final removeSids = trackPublications.keys.where((e) => !validSids.contains(e)).toSet(); for (final sid in removeSids) { await removePublishedTrack(sid); } - - return true; } Future removePublishedTrack(String trackSid, {bool notify = true}) async { diff --git a/test/core/room_e2e_test.dart b/test/core/room_e2e_test.dart index 6b96f5e3..e4813247 100644 --- a/test/core/room_e2e_test.dart +++ b/test/core/room_e2e_test.dart @@ -68,6 +68,37 @@ void main() { expect(room.remoteParticipants.length, 1); }); + test('participant join with tracks populated before connected event', () async { + // Track whether participant has tracks when connected event fires + bool participantHadTracksOnConnect = false; + int trackCountOnConnect = 0; + + // Listen for ParticipantConnectedEvent + final cancel = room.events.on((event) { + // Verify participant is fully populated with tracks + trackCountOnConnect = event.participant.trackPublications.length; + participantHadTracksOnConnect = trackCountOnConnect > 0; + }); + + // Send participant join with tracks + ws.onData(participantJoinResponse.writeToBuffer()); + + // Wait for connected event + await room.events.waitFor(duration: const Duration(seconds: 1)); + + // Clean up listener + cancel(); + + // Verify participant had tracks when connected event was emitted + expect(participantHadTracksOnConnect, isTrue, + reason: 'Participant should have tracks when ParticipantConnectedEvent is emitted'); + expect(trackCountOnConnect, greaterThan(0), + reason: 'Participant should have at least one track when connected event fires'); + + // Verify the participant is in the room + expect(room.remoteParticipants.length, 1); + }); + test('participant disconnect', () async { ws.onData(participantJoinResponse.writeToBuffer()); await room.events.waitFor(duration: const Duration(seconds: 1)); From 960727a7d5f673197ed8e6859407528975804d56 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 23 Oct 2025 22:30:16 +0700 Subject: [PATCH 2/3] analyzer fixes --- lib/livekit_client.dart | 2 +- lib/src/core/room.dart | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/livekit_client.dart b/lib/livekit_client.dart index 026b1c39..23c81fb5 100644 --- a/lib/livekit_client.dart +++ b/lib/livekit_client.dart @@ -30,7 +30,7 @@ export 'src/managers/event.dart'; export 'src/options.dart'; export 'src/participant/local.dart'; export 'src/participant/participant.dart'; -export 'src/participant/remote.dart'; +export 'src/participant/remote.dart' hide ParticipantCreationResult; export 'src/publication/local.dart'; export 'src/publication/remote.dart'; export 'src/publication/track_publication.dart'; diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index a8cd1b74..1b7abcb7 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -38,7 +38,6 @@ import '../participant/participant.dart'; import '../participant/remote.dart'; import '../proto/livekit_models.pb.dart' as lk_models; import '../proto/livekit_rtc.pb.dart' as lk_rtc; -import '../publication/remote.dart'; import '../support/disposable.dart'; import '../support/platform.dart'; import '../support/region_url_provider.dart'; From 7b735b8f925621bd1462f2185f3e767de7c22229 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 24 Oct 2025 00:23:39 +0700 Subject: [PATCH 3/3] patch 1 --- lib/src/core/room.dart | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index 1b7abcb7..d1199b03 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -640,10 +640,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable { String identity, lk_models.ParticipantInfo? info) async { RemoteParticipant? participant = _remoteParticipants[identity]; if (participant != null) { - if (info != null) { - await participant.updateFromInfo(info); - } - // Return existing participant with no new publications + // Return existing participant with no new publications; caller handles updates. return ParticipantCreationResult( participant: participant, newPublications: const [], @@ -706,11 +703,14 @@ class Room extends DisposableChangeNotifier with EventsEmittable { // Emit connected event emitWhenConnected(ParticipantConnectedEvent(participant: result.participant)); // Emit TrackPublishedEvent for each new track - for (final pub in result.newPublications) { - [events].emit(TrackPublishedEvent( - participant: result.participant, - publication: pub, - )); + if (connectionState == ConnectionState.connected) { + for (final pub in result.newPublications) { + final event = TrackPublishedEvent( + participant: result.participant, + publication: pub, + ); + [result.participant.events, events].emit(event); + } } _sidToIdentity[info.sid] = info.identity; } else {