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.

class RemoteParticipant { // Identity readonly id: string; readonly displayName: string; readonly metadata: any; readonly isLocal: false; readonly isViewer: boolean; // tier โ€” false = on-stage speaker; true = audience viewer (no media, subscribe rejects) // Stream handles โ€” null until you subscribe to that kind video: RemoteVideoStream | null; audio: RemoteAudioStream | null; screen: RemoteScreenStream | null; // Sync state getters readonly isVideoSubscribed: boolean; readonly isAudioSubscribed: boolean; readonly isScreenSubscribed: boolean; readonly pinnedKinds: MediaKind[]; // Subscribe / unsubscribe โ€” accept a single kind OR an array. Promise resolves on server ack; // stream payload arrives via 'stream-subscribed' event as each kind's pipeline becomes ready. subscribe(kinds: MediaKind | MediaKind[]): Promise<void>; unsubscribe(kinds: MediaKind | MediaKind[]): Promise<void>; // Request the remote to publish/unpublish a kind (soft request; recipient decides) requestPublishAudio(): Promise<void>; requestPublishVideo(): Promise<void>; requestPublishScreen(): Promise<void>; requestUnpublishAudio(): Promise<void>; requestUnpublishVideo(): Promise<void>; requestUnpublishScreen(): Promise<void>; // Pin this participant โ€” broadcast layout signal (per-pinner state) pin(kinds: MediaKind[]): Promise<void>; unpin(kinds: MediaKind[]): Promise<void>; // Remove (kick) โ€” permission-gated remove(): Promise<void>; // Event handlers on(event: RemoteEvent | string, listener: Function): void; off(event: RemoteEvent | string, listener: Function): void; // === Events fired === // Publish notifications โ€” fire on remote publish/unpublish (retroactive for already-published): // 'stream-published' ({ kind: MediaKind }) // 'stream-unpublished' ({ kind: MediaKind }) // Subscribe lifecycle โ€” fires per kind as pipeline becomes ready / ends: // 'stream-subscribed' (sub: Subscription) โ€” fires once per kind // 'stream-unsubscribed' ({ kind: MediaKind }) โ€” you unsubscribed OR publisher unpublished // Pin events โ€” fire when this participant is pinned/unpinned by anyone (retroactive on join): // 'pinned' ({ by, kinds }) // 'unpinned' ({ by, kinds }) }

Properties

video

The remote camera stream. Null until you subscribe to MediaKind.Video.

video: RemoteVideoStream | null

audio

The remote microphone stream. SDK auto-plays remote audio in an internal element after subscribe โ€” no manual attach needed for the common case.

audio: RemoteAudioStream | null

screen

The remote screen-share stream.

screen: RemoteScreenStream | null

State getters

readonly isVideoSubscribed: boolean readonly isAudioSubscribed: boolean readonly isScreenSubscribed: boolean

pinnedKinds

The kinds you (local) have pinned of this participant. Sync โ€” read freely from UI code.

readonly pinnedKinds: MediaKind[]

Identity

readonly id: string readonly displayName: string readonly metadata: any readonly isLocal: false

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).

readonly isViewer: boolean
Subscribe rejects on viewer instances. If 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).
No 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).

subscribe(kinds: MediaKind | MediaKind[]): Promise<void>
Parameters
  • 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.
Returns

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 lacks canSubscribe, 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.
Subscribe requires a live publication โ€” listen for 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 */ });
Arrays are all-or-nothing. 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.
Example โ€” register handler, then subscribe
// 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.
Example โ€” the recommended pattern (covers all lifetimes)
// 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.

unsubscribe(kinds: MediaKind | MediaKind[]): Promise<void>
Example โ€” drop video to save bandwidth
// 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 }.

requestPublishAudio(): Promise<void> requestPublishVideo(): Promise<void> requestPublishScreen(): Promise<void>
Returns

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.

How you learn the outcome. If the recipient accepts, you'll see 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.
Example โ€” request, then react to publish
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.

requestUnpublishAudio(): Promise<void> requestUnpublishVideo(): Promise<void> requestUnpublishScreen(): Promise<void>
This is not a hard mute. The recipient can decline. v1 does not include moderator/host force-mute APIs; we may add them later.
Example
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.

pin(kinds: MediaKind[]): Promise<void> unpin(kinds: MediaKind[]): Promise<void>
Parameters
  • kinds โ€” array of MediaKind values. Only Video and Screen are pinnable; passing Audio rejects the Promise.
Returns

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 in unpin([...]) 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]) with pinnedKinds: [Video] โ†’ resolves and fires pinned with kinds: [Screen].
  • Rejects with INVALID_KIND if Audio is in the list.
Pin events fire on the target's representation โ€” see the pinned / unpinned events below. The pinner's Promise is the result channel; no event fires on the pinner's side specifically.
Example โ€” pin a presenter's screen
// 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).

remove(): Promise<void>
Returns

Resolves when the server confirms the removal. Rejects with:

  • PERMISSION_DENIED โ€” your role doesn't allow removing this participant
  • PARTICIPANT_NOT_FOUND โ€” they already left
  • NETWORK_ERROR โ€” request failed to deliver
Other observers see this participant disappear via the existing room.on('participant-left', p) event โ€” same as a voluntary leave.
Example โ€” moderator UI
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

on(event: RemoteEvent | string, listener: Function): void off(event: string, listener: Function): void

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)PayloadWhen
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)PayloadWhen
stream-subscribedSubscription โ€” { 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.
Example โ€” symmetric attach/cleanup
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.

Example โ€” pin awareness UI
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));
SDK auto-cleanup on leave. When this participant leaves the room, the SDK invalidates all their streams, releases attached rendering targets, removes all listeners registered on this participant, and clears any pin tuples involving them (firing unpinned events on observers as needed). You don't need to manually off() handlers attached to remote participants.
Example โ€” react to publishes; subscribe; render
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