Media with the Dart SDK
Audio and video flow through the LiveKit SFU. client.joinRoom() connects media for you — the join grant's LiveKit token, URL, and server-provisioned ICE servers are wired straight into the SDK's media session, and telemetry starts automatically. You never negotiate WebRTC yourself.
Join options
final handle = await client.joinRoom(roomId,
options: const JoinOptions(
audio: true, // publish the local microphone on join (default true)
telemetry: true, // periodic QoS reporting (default true)
));
Microphone and camera
await handle.setMicEnabled(true);
await handle.setCameraEnabled(true);
// ...
await handle.setMicEnabled(false);
Request OS permissions before enabling tracks:
- iOS — add
NSCameraUsageDescriptionandNSMicrophoneUsageDescriptiontoInfo.plist. - Android — declare
CAMERA,RECORD_AUDIO, andINTERNETpermissions and request them at runtime. - Web — the browser prompts automatically on first
getUserMedia.
Media events
Media-plane changes surface on the same RoomHandle.events stream as everything else:
handle.events.listen((event) {
switch (event) {
case ActiveSpeakersChanged(:final speakers):
// speakers: List<Object> — LiveKit participants; cast for details.
case Reconnecting():
// media layer lost the SFU and is retrying
case Reconnected():
// media restored
case Disconnected(:final reason):
// media or signaling ended
default:
break;
}
});
The LiveKit escape hatch
The SDK intentionally does not wrap the full LiveKit API (ADR 0022 §2). For rendering video, screen share, or custom tracks, drop down to livekit_client via handle.livekitRoom:
import 'package:livekit_client/livekit_client.dart' as lk;
final lkRoom = handle.livekitRoom as lk.Room?;
// Render remote video and react to track events:
final listener = lkRoom!.createListener();
listener
..on<lk.TrackSubscribedEvent>((e) {
if (e.track is lk.VideoTrack) {
// Render with VideoTrackRenderer(e.track as VideoTrack) in your widget tree.
}
})
..on<lk.TrackUnsubscribedEvent>((e) {
// Remove the renderer.
});
// Screen share (mobile requires platform setup; supported on web):
await lkRoom.localParticipant?.setScreenShareEnabled(true);
Dispose your listener before handle.leave(); leave() disconnects the LiveKit room itself.
Simulcast
livekit_client publishes video with simulcast enabled by default: multiple encodings of the same track at different resolutions. The SFU forwards the layer each subscriber can handle based on its bandwidth and view size, so a weak mobile connection degrades gracefully without affecting other participants. Leave simulcast on unless you have a specific reason to disable it.
TURN is handled for you
TURN relay is operated server-side by the platform on Coturn clusters. Do not deploy or configure your own TURN servers, and do not hard-code ICE servers in the app — the join grant already carries STUN/TURN URIs with ephemeral HMAC credentials, and joinRoom() passes them to the media session.
If you need fresh ICE credentials outside the join flow (for example, an ICE restart), use the typed facade:
final ice = await client.turn.credentials();
// ice.iceServers -> List<IceServer> (urls, username, credential)
// ice.ttl -> credential lifetime in seconds
// ice.region -> serving region
This endpoint is rate limited to 6 requests per minute per user; exceeding it throws RateLimitException (check retryAfterSeconds).
QoS telemetry
With telemetry: true (the default), the SDK periodically collects connection-quality samples from the media session and batches them to the analytics service, tagged with the join grant's session_id. Aggregates come back through client.analytics (roomQuality, sessionSummary, tenantOverview).
Under the hood
The join grant (POST /v1/rooms/{id}/join) carries the media credentials:
| Field | Purpose |
|---|---|
livekit_url | WebSocket URL of the LiveKit SFU. |
livekit_token | Short-lived LiveKit access token scoped to this room and user. |
ice_servers | STUN/TURN server configuration, provisioned server-side. |
Never mint LiveKit tokens in the app; they always come from the join grant. What joinRoom() does with them is exactly the raw flow:
import 'package:livekit_client/livekit_client.dart';
final room = Room();
await room.connect(
grant.livekitUrl,
grant.livekitToken,
);
Fresh ICE credentials map to POST /v1/ice/credentials (Bearer auth):
{
"ice_servers": [ { "urls": ["..."], "username": "...", "credential": "..." } ],
"ttl": 600,
"region": "eu-central"
}
Media (LiveKit) and signaling are separate connections with the same lifecycle; joinRoom() connects both and handle.leave() tears both down (including bye and POST /v1/rooms/{id}/leave).