Overview
While connected to a room, a participant can receive and render any tracks published to the room. When autoSubscribe
is enabled (default), the server automatically delivers new tracks to participants, making them ready for rendering.
Track subscription
Rendering media tracks starts with a subscription to receive the track data from the server.
As mentioned in the guide on rooms, participants, and tracks, LiveKit models tracks with two constructs: TrackPublication
and Track
. Think of a TrackPublication
as metadata for a track registered with the server and Track
as the raw media stream. Track publications are always available to the client, even when the track is not subscribed to.
Track subscription callbacks provide your app with both the Track
and TrackPublication
objects.
Subscribed callback will be fired on both Room
and RemoteParticipant
objects.
import { connect, RoomEvent } from 'livekit-client';room.on(RoomEvent.TrackSubscribed, handleTrackSubscribed);function handleTrackSubscribed(track: RemoteTrack,publication: RemoteTrackPublication,participant: RemoteParticipant,) {/* Do things with track, publication or participant */}
import { useTracks } from '@livekit/components-react';export const MyPage = () => {return (<LiveKitRoom ...><MyComponent /></LiveKitRoom>)}export const MyComponent = () => {const cameraTracks = useTracks([Track.Source.Camera], {onlySubscribed: true});return (<>{cameraTracks.map((trackReference) => {return (<VideoTrack {...trackReference} />)})}</>)}
let room = LiveKit.connect(options: ConnectOptions(url: url, token: token), delegate: self)...func room(_ room: Room,participant: RemoteParticipant,didSubscribe publication: RemoteTrackPublication,track: Track) {/* Do things with track, publication or participant */}
coroutineScope.launch {room.events.collect { event ->when(event) {is RoomEvent.TrackSubscribed -> {/* Do things with track, publication or participant */}else -> {}}}}
class ParticipantWidget extends StatefulWidget {final Participant participant;ParticipantWidget(this.participant);State<StatefulWidget> createState() {return _ParticipantState();}}class _ParticipantState extends State<ParticipantWidget> {TrackPublication? videoPub;void initState() {super.initState();// When track subscriptions change, Participant notifies listeners// Uses the built-in ChangeNotifier APIwidget.participant.addListener(_onChange);}void dispose() {super.dispose();widget.participant.removeListener(_onChange);}void _onChange() {TrackPublication? pub;var visibleVideos = widget.participant.videoTracks.values.where((pub) {return pub.kind == TrackType.VIDEO && pub.subscribed && !pub.muted;});if (visibleVideos.isNotEmpty) {pub = visibleVideos.first;}// setState will trigger a buildsetState(() {// Your updates herevideoPub = pub;});}Widget build(BuildContext context) {// Your build function}}
@room.on("track_subscribed")def on_track_subscribed(track: rtc.Track, publication: rtc.RemoteTrackPublication, participant: rtc.RemoteParticipant):if track.kind == rtc.TrackKind.KIND_VIDEO:video_stream = rtc.VideoStream(track)async for frame in video_stream:# Received a video frame from the track, process it herepassawait video_stream.aclose()
while let Some(msg) = rx.recv().await {#[allow(clippy::single_match)]match msg {RoomEvent::TrackSubscribed {track,publication: _,participant: _,} => {if let RemoteTrack::Audio(audio_track) = track {let rtc_track = audio_track.rtc_track();let mut audio_stream = NativeAudioStream::new(rtc_track);while let Some(frame) = audio_stream.next().await {// do something with audio frame}break;}}_ => {}}}
Room.TrackSubscribed += (track, publication, participant) =>{// Do things with track, publication or participant};
This guide is focused on frontend applications. To consume media in your backend, use the LiveKit Agents framework or SDKs for Go, Rust, Python, or Node.js.
Media playback
Once subscribed to an audio or video track, it's ready to be played in your application
function handleTrackSubscribed(track: RemoteTrack,publication: RemoteTrackPublication,participant: RemoteParticipant,) {// Attach track to a new HTMLVideoElement or HTMLAudioElementconst element = track.attach();parentElement.appendChild(element);// Or attach to existing element// track.attach(element)}
export const MyComponent = ({ audioTrack, videoTrack }) => {return (<div><VideoTrack trackRef={videoTrack} /><AudioTrack trackRef={audioTrack} /></div>);};
Audio playback will begin automatically after track subscription. Video playback requires the VideoTrack
component:
export const MyComponent = ({ videoTrack }) => {return <VideoTrack trackRef={videoTrack} />;};
Audio playback begins automatically after track subscription. Video playback requires the VideoView
component:
func room(_ room: Room,participant: RemoteParticipant,didSubscribe publication: RemoteTrackPublication,track: Track) {// Audio tracks are automatically played.if let videoTrack = track as? VideoTrack {DispatchQueue.main.async {// VideoView is compatible with both iOS and MacOSlet videoView = VideoView(frame: .zero)videoView.translatesAutoresizingMaskIntoConstraints = falseself.view.addSubview(videoView)/* Add any app-specific layout constraints */videoView.track = videoTrack}}}
Audio playback will begin automatically after track subscription. Video playback requires the VideoTrack
component:
coroutineScope.launch {room.events.collect { event ->when(event) {is RoomEvent.TrackSubscribed -> {// Audio tracks are automatically played.val videoTrack = event.track as? VideoTrack ?: return@collectvideoTrack.addRenderer(videoRenderer)}else -> {}}}}
Audio playback will begin automatically after track subscription. Video playback requires the VideoTrackRenderer
component:
class _ParticipantState extends State<ParticipantWidget> {TrackPublication? videoPub;...Widget build(BuildContext context) {// Audio tracks are automatically played.var videoPub = this.videoPub;if (videoPub != null) {return VideoTrackRenderer(videoPub.track as VideoTrack);} else {return Container(color: Colors.grey,);}}}
Audio playback will begin automatically after track subscription. Video playback requires an HTMLVideoElement
:
Room.TrackSubscribed += (track, publication, participant) =>{var element = track.Attach();if (element is HTMLVideoElement video){video.VideoReceived += tex =>{// Do things with tex};}};
Volume control
Audio tracks support a volume between 0 and 1.0, with a default value of 1.0. You can adjust the volume if necessary be setting the volume property on the track.
track.setVolume(0.5);
track.volume = 0.5
track.setVolume(0.5)
track.setVolume(0.5)
Active speaker identification
LiveKit can automatically detect participants who are actively speaking and send updates when their speaking status changes. Speaker updates are sent for both local and remote participants. These events fire on both Room and Participant objects, allowing you to identify active speakers in your UI.
room.on(RoomEvent.ActiveSpeakersChanged, (speakers: Participant[]) => {// Speakers contain all of the current active speakers});participant.on(ParticipantEvent.IsSpeakingChanged, (speaking: boolean) => {console.log(`${participant.identity} is ${speaking ? 'now' : 'no longer'} speaking. audio level: ${participant.audioLevel}`,);});
export const MyComponent = ({ participant }) => {const { isSpeaking } = useParticipant(participant);return <div>{isSpeaking ? 'speaking' : 'not speaking'}</div>;};
export const MyComponent = ({ participant }) => {const { isSpeaking } = useParticipant(participant);return <Text>{isSpeaking ? 'speaking' : 'not speaking'}</Text>;};
extension MyRoomHandler : RoomDelegate {func didUpdateSpeakingParticipants(speakers: [Participant], room _: Room) {// Do something with the active speakers}}extension ParticipantHandler : ParticipantDelegate {/// The isSpeaking status of the participant has changedfunc didUpdateIsSpeaking(participant: Participant) {print("\(participant.identity) is now speaking: \(participant.isSpeaking), audioLevel: \(participant.audioLevel)")}}
coroutineScope.launch {room::activeSpeakers.flow.collect { currentActiveSpeakers ->// Manage speaker changes across the room}}coroutineScope.launch {remoteParticipant::isSpeaking.flow.collect { isSpeaking ->// Handle a certain participant speaker status change}}
class _ParticipantState extends State<ParticipantWidget> {late final _listener = widget.participant.createListener()void initState() {super.initState();_listener.on<SpeakingChangedEvent>((e) {// Handle isSpeaking change})}}
Room.ActiveSpeakersChanged += speakers =>{// Do something with the active speakers};participant.IsSpeakingChanged += speaking =>{Debug.Log($"{participant.Identity} is {(speaking ? "now" : "no longer")} speaking. Audio level {participant.AudioLevel}");};
Selective subscription
Disable autoSubscribe
to take manual control over which tracks the participant should subscribe to. This is appropriate for spatial applications and/or applications that require precise control over what each participant receives.
Both LiveKit's SDKs and server APIs have controls for selective subscription. Once configured, only explicitly subscribed tracks are delivered to the participant.
From frontend
let room = await room.connect(url, token, {autoSubscribe: false,});room.on(RoomEvent.TrackPublished, (publication, participant) => {publication.setSubscribed(true);});// Also subscribe to tracks published before participant joinedroom.remoteParticipants.forEach((participant) => {participant.trackPublications.forEach((publication) => {publication.setSubscribed(true);});});
let connectOptions = ConnectOptions(url: "ws://<your_host>",token: "<your_token>",autoSubscribe: false)let room = LiveKit.connect(options: connectOptions, delegate: self)func didPublishRemoteTrack(publication: RemoteTrackPublication, participant: RemoteParticipant) {publication.set(subscribed: true)}// Also subscribe to tracks published before participant joinedfor participant in roomCtx.room.room.remoteParticipants {for publication in participant.tracks {publication.set(subscribed: true)}}
class ViewModel(...) {suspend fun connect() {val room = LiveKit.create(appContext = application)room.connect(url = url,token = token,options = ConnectOptions(autoSubscribe = false))// Also subscribe to tracks published before participant joinedfor (participant in room.remoteParticipants.values) {for (publication in participant.trackPublications.values) {val remotePub = publication as RemoteTrackPublicationremotePub.setSubscribed(true)}}viewModelScope.launch {room.events.collect { event ->if(event is RoomEvent.TrackPublished) {val remotePub = event.publication as RemoteTrackPublicationremotePub.setSubscribed(true)}}}}}
const roomOptions = RoomOptions(adaptiveStream: true,dynacast: true);const connectOptions = ConnectOptions(autoSubscribe: false);final room = Room();await room.connect(url, token, connectOptions: connectOptions, roomOptions: roomOptions);// If necessary, we can listen to room events herefinal listener = room.createListener();class RoomHandler {Room room;late EventsListener<RoomEvent> _listener;RoomHandler(this.room) {_listener = room.createListener();_listener.on<TrackPublishedEvent>((e) {unawaited(e.publication.subscribe());});// Also subscribe to tracks published before participant joinedfor (RemoteParticipant participant in room.remoteParticipants.values) {for (RemoteTrackPublication publicationin participant.trackPublications.values) {unawaited(publication.subscribe());}}}}
@room.on("track_published")def on_track_published(publication: rtc.RemoteTrackPublication, participant: rtc.RemoteParticipant):publication.set_subscribed(True)await room.connect(url, token, rtc.RoomOptions(auto_subscribe=False))# Also subscribe to tracks published before participant joinedfor p in room.remote_participants.values():for pub in p.track_publications.values():pub.set_subscribed(True)
yield return room.Connect(url, token, new RoomConnectOptions(){AutoSubscribe = false});room.TrackPublished += (publication, participant) =>{publication.SetSubscribed(true);};
From server API
These controls are also available with the server APIs.
import { RoomServiceClient } from 'livekit-server-sdk';const roomServiceClient = new RoomServiceClient('myhost', 'api-key', 'my secret');// Subscribe to new trackroomServiceClient.updateSubscriptions('myroom', 'receiving-participant-identity', ['TR_TRACKID'], true);// Unsubscribe from existing trackroomServiceClient.updateSubscriptions('myroom', 'receiving-participant-identity', ['TR_TRACKID'], false);
import (lksdk "github.com/livekit/server-sdk-go")roomServiceClient := lksdk.NewRoomServiceClient(host, apiKey, apiSecret)_, err := roomServiceClient.UpdateSubscriptions(context.Background(), &livekit.UpdateSubscriptionsRequest{Room: "myroom",Identity: "receiving-participant-identity",TrackSids: []string{"TR_TRACKID"},Subscribe: true})
Adaptive stream
In an application, video elements where tracks are rendered could vary in size, and sometimes hidden. It would be extremely wasteful to fetch high-resolution videos but only to render it in a 150x150 box.
Adaptive stream allows a developer to build dynamic video applications without consternation for how interface design or user interaction might impact video quality. It allows us to fetch the minimum bits necessary for high-quality rendering and helps with scaling to very large sessions.
When adaptive stream is enabled, the LiveKit SDK will monitor both size and visibility of the UI elements that the tracks are attached to. Then it'll automatically coordinate with the server to ensure the closest-matching simulcast layer that matches the UI element is sent back. If the element is hidden, the SDK will automatically pause the associated track on the server side until the element becomes visible.
With JS SDK, you must use Track.attach()
in order for adaptive stream to be effective.
Enabling/disabling tracks
Implementations seeking fine-grained control can enable or disable tracks at their discretion. This could be used to implement subscriber-side mute. (for example, muting a publisher in the room, but only for the current user).
When disabled, the participant will not receive any new data for that track. If a disabled track is subsequently enabled, new data will be received again.
The disable
action is useful when optimizing for a participant's bandwidth consumption. For example, if a particular user's video track is offscreen, disabling this track will reduce bytes from being sent by the LiveKit server until the track's data is needed again. (this is not needed with adaptive stream)
import { connect, RoomEvent } from 'livekit-client';room.on(RoomEvent.TrackSubscribed, handleTrackSubscribed);function handleTrackSubscribed(track: RemoteTrack,publication: RemoteTrackPublication,participant: RemoteParticipant,) {publication.setEnabled(false);}
let room = LiveKit.connect(options: ConnectOptions(url: url, token: token), delegate: self)...func room(_ room: Room,participant: RemoteParticipant,didSubscribe publication: RemoteTrackPublication,track: Track) {publication.setEnabled(false)}
coroutineScope.launch {room.events.collect { event ->when(event) {is RoomEvent.TrackSubscribed -> {event.publication.setEnabled(false)}else -> {}}}}
void disableTrack(RemoteTrackPublication publication) {publication.enabled = false;}
room.TrackSubscribed += (track, publication, participant) =>{publication.SetEnabled(false);};
You may be wondering how subscribe
and unsubscribe
differs from enable
and disable
. A track must be subscribed to and enabled for data to be received by the participant. If a track has not been subscribed to (or was unsubscribed) or disabled, the participant performing these actions will not receive that track's data.
The difference between these two actions is negotiation. Subscribing requires a negotiation handshake with the LiveKit server, while enable/disable does not. Depending on one's use case, this can make enable/disable more efficient, especially when a track may be turned on or off frequently.
Simulcast controls
If a video track has simulcast enabled, a receiving participant may want to manually specify the maximum receivable quality. This would result a quality and bandwidth reduction for the target track. This might come in handy, for instance, when an application's user interface is displaying a small thumbnail for a particular user's video track.
import { connect, RoomEvent } from 'livekit-client';connect('ws://your_host', token, {audio: true,video: true,}).then((room) => {room.on(RoomEvent.TrackSubscribed, handleTrackSubscribed);});function handleTrackSubscribed(track: RemoteTrack,publication: RemoteTrackPublication,participant: RemoteParticipant,) {if (track.kind === Track.Kind.Video) {publication.setVideoQuality(VideoQuality.LOW);}}
let room = LiveKit.connect(url, token, delegate: self)...func room(_ room: Room,participant: RemoteParticipant,didSubscribe publication: RemoteTrackPublication,track: Track) {if let _ = track as? VideoTrack {publication.setVideoQuality(.low)}}
coroutineScope.launch {room.events.collect { event ->when(event) {is RoomEvent.TrackSubscribed -> {event.publication.setVideoQuality(VideoQuality.LOW)}else -> {}}}}
var listener = room.createListener();listener.on<TrackSubscribedEvent>((e) {if (e.publication.kind == TrackType.VIDEO) {e.publication.videoQuality = VideoQuality.LOW;}})
room.TrackSubscribed += (track, publication, participant) =>{if(publication.Kind == TrackKind.Video)publication.SetVideoQuality(VideoQuality.LOW);};