Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/participant-active-event.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@livekit/rtc-node': minor
---

Surface the `ParticipantActive` FFI event as `RoomEvent.ParticipantActive` and expose `Participant.state`.

A remote participant can only receive data messages once it reaches `ParticipantState.ACTIVE`;
until now JS had no way to observe that transition, so code waiting on `ParticipantConnected`
could send to a participant that was not yet reachable. This brings the Node SDK in line with the
Python SDK's `participant_active` event.

`Participant.state` also now reports `DISCONNECTED` once the participant is gone — both when it
departs individually and when the room itself disconnects, since a room-level disconnect is not
reported as each participant departing.
12 changes: 10 additions & 2 deletions packages/livekit-rtc/src/audio_stream_room_lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: Apache-2.0
import type { OwnedTrack } from '@livekit/rtc-ffi-bindings';
import { TrackPublishOptions } from '@livekit/rtc-ffi-bindings';
import { ParticipantState, TrackPublishOptions } from '@livekit/rtc-ffi-bindings';
import { describe, expect, it, vi } from 'vitest';
import type { AudioFrame } from './audio_frame.js';
import type { AudioStreamSource } from './audio_stream.js';
Expand Down Expand Up @@ -87,6 +87,9 @@ function makeRoom(opts: { name: string; token?: string; serverUrl?: string }): R

interface StubParticipant {
identity: string;
// Real participants always carry an `info` (createRemoteParticipant sets it
// unconditionally); cleanupOnDisconnect writes the disconnected state into it.
info: { identity: string; state: ParticipantState };
trackPublications: Map<string, { sid: string }>;
}

Expand All @@ -99,7 +102,11 @@ function attachRemoteParticipant(
for (const pub of publications) {
map.set(pub.trackSid, { sid: pub.publicationSid });
}
const participant: StubParticipant = { identity, trackPublications: map };
const participant: StubParticipant = {
identity,
info: { identity, state: ParticipantState.ACTIVE },
trackPublications: map,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
room.remoteParticipants.set(identity, participant as any);
}
Expand Down Expand Up @@ -738,6 +745,7 @@ describe('AudioStream room lifecycle', () => {
const remoteTrack = makeTrack('TR_REMOTE');
const remoteParticipant = {
identity: 'bob',
info: { identity: 'bob', state: ParticipantState.ACTIVE },
trackPublications: new Map([['TR_REMOTE', { sid: 'PUB_REMOTE', track: remoteTrack }]]),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
1 change: 1 addition & 0 deletions packages/livekit-rtc/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export {
DisconnectReason,
ParticipantKind,
ParticipantKindDetail,
ParticipantState,
} from '@livekit/rtc-ffi-bindings';
export {
ConnectionQuality,
Expand Down
13 changes: 13 additions & 0 deletions packages/livekit-rtc/src/participant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type ParticipantInfo,
ParticipantKind,
type ParticipantKindDetail,
ParticipantState,
} from '@livekit/rtc-ffi-bindings';
import {
type ByteStreamOpenCallback,
Expand Down Expand Up @@ -141,6 +142,18 @@ export abstract class Participant {
return this.info.kindDetails ?? [];
}

/**
* The participant's lifecycle state.
*
* A remote participant is only able to receive data messages once it reaches
* {@link ParticipantState.ACTIVE}. Between {@link RoomEvent.ParticipantConnected} and
* {@link RoomEvent.ParticipantActive} the participant is visible in `remoteParticipants`
* but not yet reachable.
*/
get state(): ParticipantState {
return this.info.state ?? ParticipantState.JOINING;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}

get disconnectReason(): DisconnectReason | undefined {
if (this.info.disconnectReason === DisconnectReason.UNKNOWN_REASON) {
return undefined;
Expand Down
Loading
Loading