RemoteVideoStream interface

extends VideoStream  REMOTE

A remote participant's camera stream. Adds server-driven state (streamState), simulcast quality control, and pause/resume on top of VideoStream. Access via p.video after subscribe().

Blueprint

Members added on top of VideoStream.

interface RemoteVideoStream extends VideoStream { // Combined state โ€” routing (server) + decoder (WebRTC stats) merged readonly streamState: "active" | "paused" | "frozen" | "stuck" | "ended"; readonly currentQuality: "high" | "medium" | "low"; // Simulcast layer pick โ€” what to receive setPreferredQuality(q: MediaQuality): Promise<void>; // Bundle-level byte flow control (cheaper than unsubscribe + resubscribe) pause(): Promise<void>; resume(): Promise<void>; // === Events fired === // 'state-changed' ({ state }) โ€” every streamState transition (incl. frozen / stuck / ended) // 'quality-changed' ({ prev, current }) โ€” simulcast layer switch (separate axis) }

Properties (added)

Plus inherited from VideoStream: id, codec, dimensions, frameRate, contentHint, isPlaying, isPaused, isEnded.

streamState

Combined state of this stream โ€” server-driven routing state and client-observed decoder health, merged into one value. See StreamState.

readonly streamState: StreamState // "active" | "paused" | "frozen" | "stuck" | "ended"

Routing values (server-authoritative): active, paused, ended. Decoder values (observed from WebRTC stats; only when routing is active): frozen, stuck. Bandwidth-driven simulcast layer changes are signalled separately via currentQuality + the quality-changed event.

Example โ€” show one badge per state
function updateBadge(p) {
  switch (p.video?.streamState) {
    case 'paused':   return showBadge(p, 'Paused');
    case 'frozen':   return showBadge(p, 'Frozen');
    case 'stuck':    return showBadge(p, 'Buffering');
    case 'ended':    return removeTile(p);
    default:         return clearBadge(p);   // 'active' = healthy
  }
}

currentQuality

The simulcast layer currently being received. Distinct from streamState โ€” the stream is still active when the layer drops; the layer just changed. Driven by both your setPreferredQuality() calls and server-side bandwidth adaptation.

readonly currentQuality: MediaQuality // "high" | "medium" | "low"

Methods (added)

Plus inherited from VideoStream: attach, createElement, getStats, getMediaStreamTrack.

setPreferredQuality async

Pick which simulcast layer to receive. Useful when rendering at different sizes โ€” main pane gets "high", sidebar gets "low".

Requires server-side simulcast. If simulcast is disabled, this call is a no-op (the only available layer is used).

setPreferredQuality(q: MediaQuality): Promise<void>
Parameters
  • q โ€” "high" / "medium" / "low" / "auto". "auto" lets SDK pick based on the size of attached elements.
Example โ€” different quality per render target
// Main pane
mainPane.video.setPreferredQuality('high');

// Sidebar thumbnails
for (const tile of sidebarTiles) {
  tile.video.setPreferredQuality('low');
}

pause / resume async

Stop / restart byte flow without dropping the subscription. On pause(), streamState transitions to "paused". Lighter than unsubscribe + resubscribe (no re-handshake).

pause(): Promise<void> resume(): Promise<void>
Example โ€” pause when tile scrolls offscreen
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    const p = entry.target.dataset.participantId;
    const stream = room.remoteParticipants.get(p)?.video;
    if (!stream) continue;
    if (entry.isIntersecting) stream.resume();
    else                       stream.pause();
  }
});

document.querySelectorAll('.tile').forEach(t => observer.observe(t));

Events

Two stream-level events. state-changed covers every transition of streamState โ€” routing (paused / ended) and decoder observations (frozen / stuck) merged into one signal. quality-changed covers simulcast layer switches (a separate axis from streamState โ€” the stream is still active when the layer drops). Subscribe-completion events (video-subscribed, video-unsubscribed) fire on RemoteParticipant.

EventPayload
state-changed{ state: StreamState }
quality-changed{ prev: MediaQuality, current: MediaQuality }

state-changed fires when streamState changes for any reason: routing transitions (you called pause/resume, publisher unpublished, terminal end) or client-side decoder observations (WebRTC stats detect freeze / stuck). Payload state matches the new value of streamState.

quality-changed fires when the actually received simulcast layer switches โ€” triggered by your own setPreferredQuality() call OR by server-side bandwidth adaptation. Payload prev/current are "high" / "medium" / "low"; "auto" is only a valid input to setPreferredQuality.

Example โ€” single handler for routing/decoder; separate handler for quality
p.video.on('state-changed', ({ state }) => {
  switch (state) {
    case 'paused':  tile.classList.add('paused');  break;
    case 'frozen':  tile.classList.add('frozen');  break;
    case 'stuck':   tile.classList.add('stuck');   break;
    case 'ended':   tile.remove();                 break;
    case 'active':  tile.className = '';           break;
  }
});

p.video.on('quality-changed', ({ prev, current }) => {
  qualityBadge.textContent = current.toUpperCase();   // 'HIGH' / 'MEDIUM' / 'LOW'
});
No separate frozen / unfrozen / stuck / ended events. They're folded into state-changed via the corresponding state values. Apps wanting "is it healthy?" check state === 'active'; apps wanting cleanup hooks check state === 'ended'.

See also: VideoStream RemoteParticipant StreamState MediaQuality LocalVideoStream