RemoteParticipant class
Another participant in the room. Holds their streams (after subscribe) and exposes subscribe/unsubscribe controls. Access via room.remoteParticipants.get(id) or via the participant-joined room event.
Blueprint
Full surface at a glance โ every property, method, and event on this class.
Properties
video
The remote camera stream. Null until you subscribe to MediaKind.Video.
audio
The remote microphone stream. SDK auto-plays remote audio in an internal element after subscribe โ no manual attach needed for the common case.
State getters
pinnedKinds
The kinds you (local) have pinned of this participant. Sync โ read freely from UI code.
Identity
isViewer readonly
Tier marker โ false for on-stage speakers (the participants in room.remoteParticipants), true for audience viewers (the instances returned from room.getParticipants({ tier: 'viewer' })). Set by the other participant's token (isViewer claim โ see Authentication โ Tier & participant events).
isViewer is true, the participant is in the audience tier โ they don't publish media. Calling subscribe() / publishVideo() etc. on them rejects with INVALID_PERMISSIONS. Apps render viewer-tier participants from getParticipants as roster rows (no tile, no subscribe).grant on remote participants. Only your own grant is exposed (via me.grant) โ a remote participant's full grant isn't surfaced (it's their token; privacy). You can infer what a remote can do from their behavior (whether they've published, whether moderation actions on them succeed) โ the SFU enforces all of it regardless.Methods
subscribe async
Tells the SFU to start sending bytes for the listed kinds. Accepts a single kind or an array. The Promise resolves when the SFU acknowledges the request โ fast (server ack only).
Streams are delivered via the consolidated stream-subscribed event (payload: Subscription) as each kind's pipeline becomes ready independently. Subscribing to [Audio, Video] fires the event twice โ once per kind โ so fast kinds (audio, ~100ms) render without waiting on slow kinds (video, ~200ms; screen, variable).
kindsโ a single MediaKind or an array. Use whichever reads more naturally โ single kind for one-off subscribes, array when subscribing to multiple in one round-trip.
Resolves when the subscribe request is acknowledged. Rejects with an SDKError:
NOT_PUBLISHEDโ the target hasn't published the requested kind (or any one kind in an array โ see all-or-nothing below).INVALID_PERMISSIONSโ your token lackscanSubscribe, or the target is a viewer-tier participant (isViewer: true).PARTICIPANT_NOT_FOUNDโ the participant left between your call and the server ack.NETWORK_ERRORโ request didn't deliver; SDK retried internally before giving up.
stream-published first. Calling subscribe(kind) on a kind the target hasn't published rejects with NOT_PUBLISHED. The canonical pattern is to subscribe in response to the stream-published notification โ which fires retroactively for already-published kinds and live for new ones, so a single handler covers the full lifetime:
p.on('stream-published', ({ kind }) => p.subscribe(kind));
p.on('stream-unpublished', ({ kind }) => { /* SDK already unsubscribed */ });
subscribe([Audio, Video]) rejects with NOT_PUBLISHED if either kind isn't currently published โ the Promise either resolves for the whole call or rejects for the whole call. If you want partial-success behavior, call subscribe per kind. No silent partial-success path.// Register first โ handler fires once per kind as each pipeline becomes ready
p.on('stream-subscribed', (sub) => {
// sub: Subscription โ exactly one of sub.video / .audio / .screen is set
if (sub.video) sub.video.attach(videoEl);
if (sub.screen) sub.screen.attach(screenEl);
if (sub.audio) sub.audio.setVolume(0.7);
});
// Single kind โ only if you know they've published video
await p.subscribe(MediaKind.Video);
// Or array โ multiple kinds in one round-trip (all-or-nothing)
await p.subscribe([MediaKind.Audio, MediaKind.Video]);
// Promise resolved โ request acknowledged. 'stream-subscribed' fires per kind.
// One handler covers: kinds already published when p joined,
// AND kinds published later. stream-published is retroactive.
p.on('stream-published', ({ kind }) => p.subscribe(kind));
p.on('stream-subscribed', (sub) => {
if (sub.video) sub.video.attach(videoEl);
if (sub.audio) sub.audio.setVolume(0.7);
});
unsubscribe async
Drops the subscription. Accepts a single kind or an array. The stream getter becomes null and the SDK invalidates the previous stream object.
For temporary "stop receiving bytes but keep the subscription" use stream.pause() / stream.resume() instead โ much lighter than unsubscribe + resubscribe.
// Tile scrolled offscreen, won't return soon โ drop subscription
await p.unsubscribe(MediaKind.Video);
// Tile coming back later โ resubscribe
await p.subscribe(MediaKind.Video);
// For short offscreen periods, prefer pause/resume:
await p.video.pause();
// ...
await p.video.resume();
requestPublishAudio / requestPublishVideo / requestPublishScreen async
Asks this participant to publish the corresponding kind. Soft request โ the recipient decides whether to honor it. The recipient receives a stream-publish-requested event on their LocalParticipant with { kind, from, accept, reject }.
Resolves when the request is delivered to the remote (server ack). Rejects on network failure or unknown participant. Does not resolve based on the recipient's answer โ outcome flows through normal events.
stream-published with kind: MediaKind.Video on this RemoteParticipant โ same as any voluntary publish. If they reject or ignore, you see nothing. Silence is the decline signal.p.on('stream-published', ({ kind }) => {
if (kind === MediaKind.Video) {
// Recipient accepted (or had already published)
p.subscribe(MediaKind.Video);
}
});
await p.requestPublishVideo();
// Request delivered. If 'stream-published' fires for Video, they accepted.
requestUnpublishAudio / requestUnpublishVideo / requestUnpublishScreen async
Asks this participant to unpublish the corresponding kind. Soft request โ recipient decides. Recipient gets a stream-unpublish-requested event on their LocalParticipant.
p.on('stream-unpublished', ({ kind }) => {
if (kind === MediaKind.Video) updateUI({ theyTurnedOffCamera: true });
});
await p.requestUnpublishVideo();
pin / unpin async
Pins or unpins this participant for the given kinds. Pin is a server-broadcast layout signal โ your pin is visible to other participants (so apps can render shared "X is featured by Y" UI). Each user has their own pin set; multiple users can pin the same target independently.
kindsโ array of MediaKind values. OnlyVideoandScreenare pinnable; passingAudiorejects the Promise.
Resolves when the server acknowledges the change. The event payload is the delta โ only the kinds that actually changed.
- Rejects when the call would be a complete no-op (all kinds in
pin([...])are already pinned, or all kinds inunpin([...])are not pinned). Error codes:ALREADY_PINNED,NOT_PINNED. - Resolves with partial delta when at least one kind would change. E.g.
pin([Video, Screen])withpinnedKinds: [Video]โ resolves and firespinnedwithkinds: [Screen]. - Rejects with
INVALID_KINDifAudiois in the list.
// You decide to feature Bob's screen share
await p.pin([MediaKind.Screen]);
// Read your own pin state synchronously
console.log(p.pinnedKinds); // [MediaKind.Screen]
// Later โ release the pin
await p.unpin([MediaKind.Screen]);
remove async
Removes this participant from the room. Permission-gated โ caller's role must allow removing other participants. The removed participant disconnects; on their side, the existing room.on('connection-state-changed', โฆ) event fires with e.state === ConnectionState.Disconnected and e.disconnected.reason === DisconnectReason.RemovePeer. There is no separate room.on('left', โฆ) event โ all involuntary-leave causes route through connection-state-changed (see the note in Room โ Events).
Resolves when the server confirms the removal. Rejects with:
PERMISSION_DENIEDโ your role doesn't allow removing this participantPARTICIPANT_NOT_FOUNDโ they already leftNETWORK_ERRORโ request failed to deliver
room.on('participant-left', p) event โ same as a voluntary leave.try {
await p.remove();
toast(`${p.displayName} was removed`);
} catch (err) {
if (err.code === 'PERMISSION_DENIED') showError("You don't have permission to remove participants");
}
on / off
Events
Two consolidated event families fire on a RemoteParticipant: publish notifications and subscribe lifecycle. Operational events (frozen, stuck, ended) fire on the stream itself โ see RemoteVideoStream, RemoteAudioStream, RemoteScreenStream.
Publish notifications (no stream payload)
Fire when the remote publishes/unpublishes a kind, regardless of your subscribe state. Fire retroactively for already-published kinds โ a single handler covers both already-published and future-published.
| Event (RemoteEvent) | Payload | When |
|---|---|---|
stream-published | { kind: MediaKind } | Remote published this kind. You can decide to subscribe via p.subscribe(kind). |
stream-unpublished | { kind: MediaKind } | Remote unpublished this kind. |
Subscribe lifecycle
Fire as each kind's subscription pipeline becomes ready or ends. stream-subscribed fires once per kind โ subscribing to [Audio, Video] fires twice (audio ~100ms, video ~200ms) so fast kinds render without waiting on slow kinds.
| Event (RemoteEvent) | Payload | When |
|---|---|---|
stream-subscribed | Subscription โ { kind, video?, audio?, screen? } | Pipeline ready for that kind. Exactly one of video/audio/screen is set, matching kind. |
stream-unsubscribed | { kind: MediaKind } | Subscription ended for that kind. |
stream-unsubscribed fires for both reasons โ when you call p.unsubscribe(kind), AND when the publisher unpublishes (your subscription becomes invalid in either case). Use it as the canonical "tile cleanup" hook โ by the time it fires, the SDK has already auto-detached the stream from any elements you'd attached.p.on('stream-subscribed', (sub) => {
// sub: Subscription โ exactly one of sub.video / .audio / .screen is set
if (sub.video) {
const tile = createTile(p);
sub.video.attach(tile.querySelector('video'));
document.querySelector('#grid').appendChild(tile);
}
});
p.on('stream-unsubscribed', ({ kind }) => {
// SDK has already cleared srcObject on attached elements.
// You handle the UI cleanup.
if (kind === MediaKind.Video) {
document.querySelector(`[data-pid="${p.id}"]`)?.remove();
}
});
Auto-detach. The SDK tracks every element passed to stream.attach(). When the stream ends (publisher unpublished, network died, you unsubscribed), the SDK iterates the tracked elements and clears their srcObject. You don't need to call detach() manually for cleanup โ only when you want to re-route a still-active stream to a different element.
Pin events
Fire when this participant is pinned or unpinned by anyone (you or another participant). Fire retroactively on join for any pins already in effect โ same retroactive pattern as participant-joined.
| Event (RemoteEvent) | Payload |
|---|---|
pinned | { by: LocalParticipant | RemoteParticipant, kinds: MediaKind[] } |
unpinned | { by: LocalParticipant | RemoteParticipant, kinds: MediaKind[] } |
by is the pinner. Check by.isLocal to distinguish your own action from someone else's.
p.on('pinned', ({ by, kinds }) => {
if (by.isLocal) toast(`You pinned ${p.displayName}`);
else toast(`${by.displayName} pinned ${p.displayName}`);
});
p.on('unpinned', ({ by, kinds }) => updatePinBadges(p));
unpinned events on observers as needed). You don't need to manually off() handlers attached to remote participants.room.on('participant-joined', (p) => {
// Notification: any kind published โ decide to subscribe
p.on('stream-published', ({ kind }) => p.subscribe(kind));
// Subscribe-completion: stream is ready โ render and wire up operational events
p.on('stream-subscribed', (sub) => {
if (sub.video) {
const tile = sub.video.createElement();
tile.dataset.participantId = p.id;
document.querySelector('#grid').appendChild(tile);
sub.video.on('frozen', () => showFreezeBadge(p.id));
sub.video.on('unfrozen', () => hideFreezeBadge(p.id));
sub.video.on('ended', () => removeTile(p.id));
}
if (sub.audio) sub.audio.setVolume(0.7);
});
});
See also: RemoteVideoStream RemoteAudioStream RemoteScreenStream LocalParticipant
Related open questions: Q1 โ Per-kind events vs single event with kind Q4 โ Custom / auxiliary tracks Q11 โ Event mirroring on room