A track is one stream of media. This page is the lifecycle: creating, publishing, rendering, muting, and taking it down.
Two ways to publish
The short way covers most applications:
const camera = await room.enableCamera();
const mic = await room.enableMicrophone();
const share = await room.enableScreenShare();Each captures, publishes, and returns the LocalTrack — or undefined if
that source was already on.
The explicit way, when you need the track before it is published — a pre-join preview, a specific device, effects applied first:
const track = await client.createCameraTrack(deviceId);
// inspect, attach locally, apply effects…
await room.publish(track);await room.unpublish(track);
track.stop(); // release the camera lightA source Livqeno does not capture
For anything the SDK has no capture path for — a canvas.captureStream()
frame source, a Web Audio graph, a decoded file, a virtual camera — wrap
the MediaStreamTrack you already have:
const canvasTrack = canvas.captureStream(30).getVideoTracks()[0];
const track = client.createCustomTrack(canvasTrack, { source: 'camera' });
await room.publish(track);source is what the SFU announces to everyone else, so it decides which
tile a subscriber puts the track in; it defaults to camera for video and
microphone for audio. Everything downstream — muting, stats, effects,
subscriber events — behaves exactly as it does for a captured track.
Livqeno never captured this track, so stopping the canvas, the oscillator or
the file stays yours to do. room.unpublish(track) stops the track itself,
as it does for every other kind.
Building a LocalTrack by hand does not work, and the refusal is
deliberate: publishing needs a delegate the SDK can hand an RTCRtpSender
to. createCustomTrack() is that delegate.
unpublish() stops sending. stop() releases the hardware. Do both when
you are finished with a track you created yourself.
Rendering
const element = track.attach();
container.append(element);attach() returns an element rather than taking one, so the SDK can set
autoplay, playsInline and muted correctly. Those three are the
difference between video that plays and video that silently does not,
especially on iOS Safari.
Pass your own element when you need to control it:
track.attach(myVideoElement);Then clean up:
track.detach(); // every element this track is attached to
track.detach(myVideoElement); // just onedetach() stops playback, which is what actually saves the decode when a
tile scrolls out of view.
Mute is not unpublish
await camera.mute(); // stop sending, stay published
await camera.unmute(); // instantMuting keeps the track and its transceivers in place. Unmuting needs no
renegotiation, and remote peers see trackMuted/trackUnmuted rather
than trackUnpublished — so their UI keeps your tile and shows a badge
instead of tearing the tile down and rebuilding it.
Unpublishing removes the track entirely. Use mute for "microphone off", unpublish for "camera feature turned off".
mute()/unmute() are LocalTrack methods. You cannot mute someone
else's track — only stop subscribing to it.
Track kinds
type TrackKind = 'camera' | 'microphone' | 'screenShare' | 'unknown';unknown is honest rather than a fallback guess. WebRTC carries no notion
of what a stream is of, so Livqeno declares the source explicitly when
publishing; a track that arrives without one is reported as unknown rather
than assumed to be a camera.
That declaration is why every other client can put a screen share in the big tile.
Devices
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 replaces the media on the already-published track — no renegotiation, and nobody else in the room notices.
setSpeakerDevice() needs HTMLMediaElement.setSinkId, which Safari does
not implement; there it throws DEVICE_NOT_FOUND rather than silently
doing nothing.
React to devices appearing and disappearing:
const stop = client.onDeviceChange(async () => {
setCameras(await client.getDevices('videoinput'));
});On Flutter, room.switchCamera() flips front/rear — a phone-specific
concern with no web equivalent.
Effects
const camera = await room.enableCamera();
await camera.attachEffects(pipeline);
await camera.detachEffects();Camera tracks only; calling it on a microphone throws MEDIA_ERROR. The
processed track is swapped onto the existing sender, so no renegotiation
happens and no remote participant sees an interruption. See
Effects.
Per-track statistics
const stats = await track.getStats();
// bitrateBps (bits per second), packetsLost, packetLossPercent,
// jitterMs, roundTripTimeMs, codec, frameWidth, frameHeight, framesPerSecondbitrateBps is undefined on a track's first sample — there is nothing to
diff against yet. roundTripTimeMs appears only on send-direction tracks,
because WebRTC never reports a receiver's own RTT. codec appears only on
received video.
For the whole room at once, use
room.getConnectionStats().
Simulcast layers
Video is published in multiple qualities where the platform supports it, and the media server forwards the layer a subscriber can use.
Explicit per-participant layer selection is exposed on Flutter only
today — RavenRoom.requestLayer(participant, kind, layer). The signaling
protocol carries the frame, but @ravenkash/rtc neither sends it nor offers
a method. On the web, control what you render and detach() what you hide.
See Known limitations.
A layer switch also needs keyframe detection, which covers VP8, VP9 and H.264. On AV1 or H.265 a switch cannot complete, so single-layer is the practical choice there.
Next steps
- Audio & video — the per-platform reference.
- Screen sharing · Events · Diagnostics