livqenoDocs

RTCTracks & Publishing

Tracks & Publishing

Publish, show, mute, and remove tracks. Switch devices and read stats.

A track is one stream of audio or video, like your camera or mic. This page covers a track's whole life: create it, send it, show it, mute it, and remove it.

Use this page when the simple enableCamera() buttons from Audio & Video aren't enough: for example a preview screen, a custom video source like a canvas, or a quality indicator per person. All code runs in your app (@livqeno/rtc unless noted).

createcamera onpublishothers subscribe and see itmute / unmuteunpublishothers stop seeing itstopcamera light off
A track's lifecycle.

Two ways to publish

The quick way (most apps)

const camera = await room.enableCamera();
const mic = await room.enableMicrophone();
const share = await room.enableScreenShare();

Each call captures the device, sends it, and returns the LocalTrack. If that device was already on, it returns undefined.

The step-by-step way

Use this when you need the track before sending it. For example, for a preview before joining, a specific device, or a check before going live.

const track = await client.createCameraTrack(deviceId);
// inspect, attach locally for a preview…
await room.publish(track);

When you're done:

await room.unpublish(track);
track.stop();               // release the camera light
CallWhat it does
room.unpublish(track)Stops sending the track.
track.stop()Releases the camera or mic (the camera light turns off).

Do both when you're done with a track you created yourself.

A source Livqeno does not capture

Want to send something the SDK can't capture itself? For example, a whiteboard drawn on a canvas.captureStream(), a Web Audio graph, a video file, or a virtual camera. Wrap your own MediaStreamTrack (the browser's raw audio/video track object):

const canvasTrack = canvas.captureStream(30).getVideoTracks()[0];
const track = client.createCustomTrack(canvasTrack, { source: 'camera' });
await room.publish(track);
  • source tells others what the track is, so they know which tile to put it in. Default: camera for video, microphone for audio.
  • Muting, stats, and events work the same as for any other track.
  • You stop your own source (the canvas, the oscillator, the file). room.unpublish(track) stops the track itself.
  • You can't build a LocalTrack by hand. Always use createCustomTrack().

Show a track

To show a track, attach it to an <video> or <audio> element. You usually do this for remote tracks (other people) in trackSubscribed, and for your own camera if you want a self-view.

const element = track.attach();
container.append(element);

attach() creates the element for you. It sets autoplay, playsInline, and muted correctly. Without these, video often doesn't play, especially on iOS Safari.

Want to use your own element?

track.attach(myVideoElement);

Clean up when you're done:

track.detach();                  // every element this track is attached to
track.detach(myVideoElement);    // just one

detach() stops playback. With adaptiveStream on (the default), a video detached from every element is also paused at the server, so it stops using bandwidth too. See Pause a video.

Mute is not unpublish

await camera.mute();      // stop sending, stay published
await camera.unmute();    // instant
MuteUnpublish
The trackStays in the roomIs removed
Coming backInstantNeeds to publish again
Others gettrackMuted / trackUnmutedtrackUnpublished
Others' UIKeeps your tile, shows a muted iconRemoves your tile
Use it for"Mic off" buttonTurning a feature off

mute() and unmute() only work on your own tracks (LocalTrack). You can't mute someone else. You can only stop subscribing to them.

Track kinds

Your layout usually depends on what a track is: a camera goes in the person's tile, a screen share goes in the big tile.

type TrackKind = 'camera' | 'microphone' | 'screenShare' | 'screenShareAudio' | 'unknown';

WebRTC itself doesn't know if a stream is a camera or a screen. So Livqeno labels each track when it's published. This is how other users know to show a screen share in the big tile.

A track that arrives without a label is unknown. Livqeno doesn't guess.

TrackKind tells you how a track was captured — camera, mic, or screen. A remote participant's LiveTrackInfo.kind, from the server, only tells you whether it's audio or video. Use trackKindToMediaKind() to turn one into the other when you need to compare them:

type MediaKind = 'audio' | 'video' | 'unknown';
 
function trackKindToMediaKind(kind: TrackKind): MediaKind;

This only works one way. You can turn 'camera' into 'video', but you can't turn 'video' back into 'camera' or 'screenShare' — once you only have the media type, that detail is gone.

Devices

Let users choose which camera, mic, or speaker to use (web only):

const cameras = await client.getDevices('videoinput');
const mics = await client.getDevices('audioinput');
const speakers = await client.getDevices('audiooutput');
 
await room.setCameraDevice(cameras[0].deviceId);
await room.setMicrophoneDevice(mics[0].deviceId);
await room.setSpeakerDevice(speakers[0].deviceId);
  • Switching a device swaps the media on the track you're already sending. Nobody else in the room notices.
  • setSpeakerDevice() needs HTMLMediaElement.setSinkId. Safari doesn't support it, so it throws DEVICE_NOT_FOUND there.

Update your list when devices are plugged in or out:

const stop = client.onDeviceChange(async () => {
  setCameras(await client.getDevices('videoinput'));
});

On Flutter, room.switchCamera() flips between the front and back camera.

Stats for one track

Use per-track stats to explain why one person looks bad, for example a "poor connection" badge on just bob's tile.

const stats = await track.getStats();
// bitrateBps (bits per second), packetsLost, packetLossPercent,
// jitterMs, roundTripTimeMs, codec, frameWidth, frameHeight, framesPerSecond
  • bitrateBps is undefined on the first sample. It needs two to compare.
  • roundTripTimeMs only appears on tracks you send.
  • codec only appears on video you receive.

For the whole room at once, use room.getConnectionStats().

Simulcast layers

Simulcast means your video is sent in several qualities at once (where the platform supports it). The media server then sends each viewer the quality that suits them: a viewer on a weak phone network gets the low one, a viewer on fast Wi-Fi gets the high one. Nobody's bad connection drags down everyone else.

  • By default the SDK chooses for you. On the web, adaptiveStream is on by default: each remote video gets the quality that fits its tile size.
  • To choose yourself on the web, call track.setLayer('low' | 'medium' | 'high' | 'auto') on a remote video track. For example, 'low' for a tiny thumbnail and 'auto' to hand control back to the SDK. On Flutter, use LivqenoRoom.requestLayer(participant, kind, layer).
  • Hidden tiles are paused, not just shrunk: see Pause a video.
  • Your own camera only sends the qualities somebody is actually watching. If every viewer is on a small tile, the high-quality copy is switched off until someone needs it, which saves your upload and CPU. This is automatic.
  • Switching quality works with VP8, VP9, and H.264 simulcast, and with VP9 and AV1 SVC (see Choose a video codec). With H.265 it can't switch, so use a single quality there.

See Known limitations.

Choose a video codec

By default the camera publishes the browser's default codec (VP8 in Chrome) as three simulcast qualities. Set videoCodec to pick another:

const client = createRTCClient({ token, endpoint, videoCodec: 'vp9' });
videoCodecWhat is sentUse it for
'vp8' (default in Chrome)Three simulcast qualitiesCalls, the safest choice
'h264'Three simulcast qualitiesHardware encoding on phones; a live-stream host, whose HLS/RTMP output can then copy the video instead of re-encoding it
'vp9'SVC: one stream with three spatial and three temporal layers inside it (L3T3_KEY)Better quality per bit, especially screen content
'av1'SVC, as VP9Best compression; costs more CPU to encode

With SVC the media server thins the one stream per viewer by dropping layers, so track.setLayer(), adaptive streaming and track.layer work exactly as with simulcast. room.simulcastStatus('camera') reports 'svc'. If the browser can't encode the codec, or can't do L3T3_KEY, the camera falls back to VP8 simulcast and the call carries on.

preferredVideoCodec (from earlier releases) is an alias of videoCodec; when both are set, videoCodec wins.

React Native supports 'vp8' and 'h264' only: react-native-webrtc can't set an SVC mode.

Audio redundancy (RED)

Every SDK offers Opus with RED (RFC 2198) first. Each audio packet also carries the previous one, so a single lost packet is recovered from the next and the listener hears nothing. It costs roughly 30 kbps more upload. Listeners whose app doesn't support RED get plain Opus from the server.

On by default. Turn it off with audioRed: false (web and React Native) or Livqeno(audioRed: false) (Flutter). It is off automatically while end-to-end encryption is configured.

Pause a video

A remote video you can't see still costs download bandwidth and decoding, which adds up in a big grid. The server can stop sending it without unsubscribing: resuming takes a fraction of a second and no renegotiation.

Automatically (web, default). With adaptiveStream on, the SDK watches every element a remote video is attached to. When none of them can be seen for half a second, it pauses the video. It counts as hidden when the element is scrolled out of view, detached, removed from the page, or zero size, or when the browser tab is in the background (a picture-in-picture video keeps playing). It resumes as soon as the element is visible again.

By hand, on any video you receive:

track.setEnabled(false);   // pause: nothing is sent to you
track.setEnabled(true);    // resume
track.isEnabled;           // false while paused by you

setEnabled(false) wins over everything else: neither adaptive streaming nor setLayer() turns it back on. A layer pinned with setLayer() is never paused for being hidden, because pinning means you want that video. Audio is never paused.

Renderers the SDK can't watch (a <canvas>, WebGL, or a React Native view) can report visibility themselves with track.setVisible(visible). React Native's LivqenoVideoView does this for you; see React Native.

Is it playing?

Every remote track says whether the server is sending it:

track.subscriptionStateMeaningShow
'active'Video is arriving.The video.
'paused_bandwidth'Your connection can't carry even the lowest quality right now. The server resumes it when it can.A "paused: weak connection" overlay, not a frozen frame.
'paused_client'You paused it (or it was never asked for; see below).Whatever your app shows for an off tile.

track.layer is the quality being sent: 'low', 'medium', 'high' or 'off'. Both change with the room's trackSubscriptionStateChanged event.

Join with video off

In a large room, join with every remote video paused and only turn on the ones you show:

const client = createRTCClient({ ...grant, autoSubscribe: false });

Each remote video then starts as 'paused_client'. It starts arriving when you attach it to a visible element (with adaptiveStream on), or when you call track.setEnabled(true) or track.setLayer(...). Audio always arrives.

Next steps