voltkit_rtc 0.7.0
voltkit_rtc: ^0.7.0 copied to clipboard
Flutter SDK for VoltKit RTC: realtime audio and video rooms, in-room and standalone chat, and live broadcast publishing over WHIP.
voltkit_rtc #
Flutter SDK for VoltKit RTC: realtime audio and video rooms, chat, and live broadcast publishing.
| Rooms | StreamClient — join, publish camera and mic, render everyone else, moderate |
| Chat | on the room's own connection, or ChatClient for chat with no media |
| Broadcast | BroadcastPublisher — go live to an audience of any size over WHIP |
Your backend mints a short-lived token and hands the app { token, wsUrl }.
This package turns that into a live session. The API secret never reaches the
device.
flutter pub add voltkit_rtc
It builds on flutter_webrtc 1.6
and works with current Flutter on Android (minSdk 24) and iOS. There is nothing
else to install or patch.
Coming from the unpublished stream_client package?
Only the package name changed. Class names, methods and events are the same.
dependencies:
voltkit_rtc: ^0.2.0 # was: stream_client: { path: ... }
import 'package:voltkit_rtc/voltkit_rtc.dart'; // was: package:stream_client/stream_client.dart
Quick start #
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:voltkit_rtc/voltkit_rtc.dart';
final client = StreamClient(wsUrl: wsUrl, token: token);
// Wire the events BEFORE connecting: connect() consumes everyone who is
// already publishing, and those tracks arrive during the call.
final renderer = RTCVideoRenderer();
await renderer.initialize();
client.onTrack.listen((t) {
if (t.kind == 'video') renderer.srcObject = t.stream;
});
client.onTrackEnded.listen((e) => renderer.srcObject = null);
client.onClosed.listen((reason) => Navigator.pop(context));
final join = await client.connect();
if (join.role.canPublish) {
await client.publish(); // camera + mic, simulcast
}
Always await client.close() when leaving the screen. A client whose reference
is dropped without closing keeps its reconnect loop running.
Permissions #
flutter_webrtc needs camera and microphone permission before publish().
Android — android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
Minimum SDK 21, and android:usesCleartextTraffic is not required — signaling
is wss and media is DTLS-SRTP.
iOS — ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>Used to broadcast video when you go live.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Used to broadcast audio when you go live.</string>
Add audio to UIBackgroundModes if a broadcast should survive the app going
to background.
Roles #
The role is decided by your backend and signed into the token; the client cannot change it, and the media node enforces it.
| Role | canPublish |
canModerate |
|---|---|---|
host |
yes | yes |
cohost |
yes | no |
moderator |
no | yes |
viewer |
no | no |
if (client.role.canPublish) await client.publish();
Events #
All broadcast streams, safe to listen to more than once.
| Stream | Fires when |
|---|---|
onScreenShareStarted |
A screen is published; carries its local MediaStream. |
onScreenShareEnded |
Screen publishing ended (explicitly or when the platform reports the track ended). |
onScreenSharePaused |
The iOS ReplayKit preview paused/resumed capture; carries true/false. |
onTrack |
someone's audio or video is ready to render |
onTrackEnded |
that track went away |
onParticipantJoined / onParticipantLeft |
roster changes |
onParticipantMuted |
moderation locks changed: {identity, kind, paused}; roster muted / videoBlocked seed locks on reconnect |
onRoleChanged |
someone was promoted or demoted |
onHandRaised |
a hand went up or down; reason is declined, withdrawn or granted |
onForceMuted |
a moderator switched your mic or camera off, or released it |
onStreamInterrupted |
media stopped but the connection is fine — the publisher is reconnecting |
onSubscribeError |
one remote track could not be subscribed to |
onTrackMuted |
someone muted their mic, turned their music off or covered their camera, or un-did it; source says which |
onClaimChanged |
an exclusive slot was taken, moved, freed, locked, or its holder went away |
onParticipantUpdated |
someone's display metadata changed mid-session |
onRoomMetadataChanged |
room-scoped metadata changed — a board's seat format, say |
onPublishWarning |
publishing worked, but something affecting quality did not apply |
onMessage |
a chat message arrived — yours included, so render from this stream |
onMessageDeleted |
a message was removed, by its author or a moderator |
onChatMuted |
someone was muted or unmuted in chat; check identity against your own |
onTyping |
someone else started or stopped typing |
onUsageLimit |
the account behind the session ran out of minutes or hit a usage limit; the session ends at endsAt unless topped up |
onWalletLow |
wallet billing: the funds cover less than the grace period (remainingSeconds), or a recharge lifted it (cleared) |
onRoomClosed |
the room was ended, with the reason: quota-exhausted when the funds ran out. onClosed follows |
onKicked |
a moderator removed you (by, reason). onClosed follows |
onMustReconnect |
the server is moving this client (a promotion, say); handled for you, this says why |
onConnectionStateChanged |
connecting, connected, reconnecting, closed (see Session state) |
onTokenExpiring |
the token you would reconnect with expires in a minute and nobody refreshed it: call updateToken |
onNetworkQuality |
about every 3 s while listened to: level, rttMs, lossPct, availableKbps |
onError |
warnings and refusals as RpcErrors with registry codes: what onPublishWarning says, plus replaced and reconnect-gave-up |
onClosed |
terminal; nothing follows |
Moderation #
Moderator-only, and enforced at the SFU rather than asked of the target's client — the packets stop at the server, so nothing the target does can override it.
await client.forceMute(identity); // mic off
await client.forceMute(identity, kind: 'video'); // camera off
await client.forceMute(identity, kind: 'video', paused: false); // give it back
await client.promote(identity, Role.cohost);
await client.kick(identity);
await client.endRoom();
forceMute replaces requestMute, which still works and means
forceMute(identity, kind: 'audio').
The target cannot switch a force-muted kind back on — the server refuses it —
so render onForceMuted as a locked button, not a disabled feature. Its
kind says which, and paused: false means control was handed back. Leaving
the button live means the user taps it, nothing happens, and the app looks
broken rather than moderated.
The rest of the room needs no extra work: the result arrives on onTrackMuted
like any other pause.
Exclusive slots (seats on a board) #
A slot is a named thing in a room that at most one participant may hold. Seats on a 20-seat audio board or a 9-seat video board are the motivating case, but the service never learns that — a key is an opaque string.
await client.claim('seat-13'); // take it, or throw slot-taken
await client.claim('seat-13', identity: 'guest-1'); // moderator, on someone's behalf
await client.release('seat-13');
await client.blockSlot('seat-20'); // lock it; moderator only
await client.unblockSlot('seat-20');
await client.setMetadata({'displayName': 'Priya'});
// room-scoped, moderator-only, persisted — not just broadcast
await client.setRoomMetadata({'seatFormat': 10},
requireFree: ['seat-11', 'seat-12']); // refuse if those are taken
setRoomMetadata merges one key deep (a null value deletes a key; replace: true swaps the whole object), and persists — a client joining afterwards
reads the new value from JoinResult.metadata. A broadcast-only change would
be right for whoever was present and wrong for everyone who arrived later,
which is invisible to the person who made it.
requireFree is checked and applied together, so nothing can be claimed in
between. Use it when a change invalidates slots — shrinking a board from twenty
seats to ten — and the refusal names them:
try {
await client.setRoomMetadata({'seatFormat': 10},
requireFree: [for (var i = 11; i <= 20; i++) 'seat-$i']);
} on RpcError catch (e) {
if (e.code == 'slot-occupied') {
final taken = (e.data?['slots'] as List?)?.cast<String>() ?? [];
// "Clear seats 13 and 17 first."
}
}
The service cannot work those slots out for you: metadata is opaque to it, so twenty-seats-becomes-ten is only a different blob. Naming them keeps that knowledge in your app while the check stays atomic.
join() seeds the board: result.claims, result.blockedSlots, and
result.metadata (room-scoped, set by your backend at room creation — a seat
format, say). getRoomState returns the same after a reconnect.
onClaimChanged carries a state, and away is not free.
| State | Draw it as |
|---|---|
held |
taken |
away |
taken, greyed — the holder's socket dropped and the slot is still theirs |
free |
empty |
blocked |
locked |
Rendering away as free shows a viewer their own seat apparently empty and
then refuses to let them retake it. It is not a rare state: every guest granted
a seat while on a relay node passes through it, because that promotion moves
them to the origin and closes their socket on the way. A claim is held for
about a minute after a disconnect and only the same identity can reclaim it.
Rules worth knowing: one claim per identity — claiming a second releases the
first, which is what moving seats means. blockSlot refuses an occupied
slot (slot-occupied) rather than evicting; remove, then lock. Any
moderator can release any slot, so a seat stranded by a half-finished demote
is recoverable by anyone.
Claims and roles are deliberately orthogonal: demoting someone does not free their slot, and your app should release it.
Hands and layers:
await client.raiseHand(metadata: {'slot': 'seat-13'}); // which seat you want
await client.lowerHand(); // withdraw
await client.lowerHand(identity: 'priya'); // DECLINE — moderators only
// the host reads it back off the event:
client.onHandRaised.listen((h) {
if (h.raised) print('${h.identity} wants ${h.slot}');
else if (h.wasDeclined) print('declined by ${h.by}'); // clear the countdown
else if (h.wasGranted) print('seated'); // drop it from the queue
});
await client.lowerHand();
await client.setPreferredLayers(track.consumerId, 0); // lowest rung
setPreferredLayers overrides the SFU's own choice for one subscription. You
rarely want it — the SFU picks per viewer from their available bandwidth — but
it is the lever for "this tile is a thumbnail, don't send it 720p". Pass
RemoteTrack.consumerId, which arrives on onTrack.
PK battles: two rooms at once #
One identity can hold two rooms simultaneously — session replacement is scoped by room, so the second connection does not displace the first. Each room keeps its own roster, producers and events; nothing merges. No server-side PK feature is involved.
See example/pk_battle.dart for the full pattern,
including dropping a simulcast layer because each tile is half width. That file
is compiled by flutter analyze, so unlike a snippet it cannot quietly rot.
onTrackMuted is not optional to render. A paused video producer stops sending
frames and the renderer keeps showing the last one it decoded, so a host who
covers their camera becomes a frozen still of themselves — indefinitely, and
indistinguishable from the stream having hung. There is no frame-level signal on
RTCVideoRenderer to infer this from (onResize fires on a size change, not a
frame), so this event is the only way to know. It also fires on join for anyone
already muted, so a late viewer is not left looking at a black tile with no
explanation.
onSubscribeError is per producer, and the join still succeeds around it. Every
producer is subscribed to independently, so one failure costs you that one
track, not the rest of the room. Listening is optional — show the affected
participant as unavailable, or retry — but ignoring it means a missing tile with
no explanation.
Publish quality #
publishStream() defaults to a broadcast profile, which wants the opposite
trade-offs from a video call:
await client.publishStream(stream); // broadcast defaults
await client.publishStream(stream, video: const VideoPublishOptions(
degradationPreference: RTCDegradationPreference.MAINTAIN_FRAMERATE,
layers: 3, // call-like profile
));
| Default | Why |
|---|---|
preferHardwareCodec: true |
H264 when the device has it. Phones have hardware H264 encoders and essentially none has one for VP8 — this is the single biggest lever. |
degradationPreference: MAINTAIN_RESOLUTION |
Under CPU load, drop framerate rather than resolution. WebRTC defaults to the reverse, which is right for a call and wrong when viewers are reading text. |
layers: 2 |
Each layer is a full encode. Three software encodes on a mid-range phone force the capture itself down a rung before anything reaches the wire. |
maxFramerate: 30 |
Layers budget for this whether or not the camera reaches it; unset, each one plans for 60 and spends bitrate on frames instead of pixels. |
quality: null |
The highest quality the project allows (see below). |
topBitrate: null |
6 Mbps at 1080p, 3 Mbps at 720p, 1.2 Mbps at 480p (30 fps). |
Video quality: 1080p, 720p, 480p #
await client.publish(video: const VideoPublishOptions(quality: VideoQuality.p1080));
client.videoPolicy.maxQuality; // what this project may publish: p720 or p1080
client.videoQuality; // what is being published, after that limit
- 1080p, 720p and 480p are included on every plan. Up to 60 fps.
videoPolicy.maxQualityandmaxFrameRatedescribe any administrator restriction for this project; defaults are 1080p and 60 fps. - Unset
qualitychooses the highest allowed. Captured pictures are scaled down to fit, never upscaled. Portrait camera frames keep their orientation. - The default is 30 fps. Opt in with
frameRate: 60(the previousmaxFramerateoption remains supported). Only the top simulcast layer runs at 60; lower layers stay at 30. A 60 fps top layer uses 1.5× its 30 fps bitrate: 9 Mbps at 1080p, 4.5 Mbps at 720p, 1.8 Mbps at 480p. - 60 fps roughly doubles encoding work and increases heat, battery use and
data. Many phone front cameras provide only 30 fps. Capture constraints and
sustained outbound statistics trigger a 30 fps fallback, reported as
frame-rate-fallbackthroughonPublishWarning. Native platforms without usable statistics still get WebRTC's own adaptive encoding. - Every publisher normally has a 25 Mbps incoming safety cap, covering camera, screen and audio together. It is an anti-abuse limit, not a plan quality gate.
At 30 fps, the default two-layer phone profile sends:
| Quality | Copies | Bitrates | Host upload, excluding audio |
|---|---|---|---|
| 1080p | 960×540 · 1920×1080 | 2 Mbps · 6 Mbps | about 8 Mbps |
| 720p | 640×360 · 1280×720 | 800 kbps · 3 Mbps | about 3.8 Mbps |
| 480p | 427×240 · 854×480 | 400 kbps · 1.2 Mbps | about 1.6 Mbps |
With layers: 3, 720p and 1080p add a quarter-size layer: 320x180 at
200 kbps, or 480x270 at 400 kbps. A soft picture usually comes from the phone
(too busy to encode) or its upload (too slow for the top layer):
troubleshooting shows how to tell
which, and what to do about each.
The full guide, with data use and troubleshooting: voltkit.in/docs/video-quality.
publish() forwards these too — publish(video: const VideoPublishOptions(...))
— and the profile is remembered, so a reconnect republishes with the same
one. Without that a host failing over to a new origin silently reverts to the
defaults, which reads as "the setting did not work" rather than "the setting
stopped being applied".
Audio quality: standard or HD #
The microphone goes out as standard audio unless you ask for HD:
AudioQuality |
Sent as | Billed as (wallet billing) |
|---|---|---|
std (default) |
mono Opus, maxaveragebitrate 64000, the sender held at 60 kbps, DTX and FEC |
standard audio |
hd |
stereo Opus, maxaveragebitrate 128000, DTX and FEC |
HD audio |
await client.publish(audioQuality: AudioQuality.hd);
await client.setAudioQuality(AudioQuality.std); // while live; resolves to the quality in use
client.audioQuality; // AudioQuality.std or .hd
The server bills what it can verify, never what a client claims: HD only when
the microphone declares more than 64 kbps or is measured above 72 kbps, so a
standard microphone really stays at or under 64 kbps (its sender is held at
60 kbps because Opus overshoots its target a little). Both qualities declare
themselves to the server. Switching re-creates the microphone's producer (its
codec settings are fixed per producer): listeners hear a gap of a fraction of a
second, and a muted microphone stays muted. Music (publishMusic) is a
separate 128 kbps stereo track and is not affected: it is HD and free whatever
the microphone's quality (see Music).
Changing quality while live #
await client.setVideoQuality(VideoQuality.p720); // resolves to the quality used
await client.setVideoFrameRate(60); // 30 or 60; resolves to the rate used
client.videoQuality; // after the project's limits
client.videoFrameRate; // 30 or 60
final stats = await client.getVideoStats(); // per layer: size, fps, bytes, limitation
Each replaces the camera's producer, so viewers see a brief pause while the new
one starts; a camera that is off stays off, and the screen, microphone and music
are untouched. The picture is scaled down to a quality, never up, so capture at
least that size: publish() captures the quality it publishes. A camera
captured at 30 fps cannot be sent at 60 (frame-rate-fallback): publish with
frameRate: 60 to capture at 60 first. getVideoStats() is null while no
camera is published.
Stopping #
unpublish() releases the camera and microphone as well as closing the
producers. It has to: producers are created with stopTracks: false so a
reconnect can republish the same tracks, which means nothing else would let the
device go. A co-host who steps down and then takes a seat again would otherwise
ask Android for a camera the process still holds, and get a track that never
produces a frame — signalling all succeeds and the tile stays black.
A stream you passed to publishStream is yours and is left alone; one
publish() acquired is disposed.
It also drops the send transport, so the next publish() builds a fresh
one. A transport whose producers are closed and whose tracks are stopped does
not reliably carry media again — and the failure is invisible from the outside:
produce succeeds over the websocket, the server creates the producer and
announces it, viewers consume and resume it, and no RTP ever arrives. Perfect
state, dead pipe. Rebuilding costs a transport setup on a second promotion and
removes a class of bug that reads as everything-but-the-video working.
setCamEnabled(false) is a different thing: it pauses the producer and keeps
the device, so unmuting is instant.
To bisect a codec-specific problem, force one outright:
await client.publishStream(stream,
video: const VideoPublishOptions(codecMimeType: 'video/VP8'));
preferHardwareCodec: false is not the way to do that — it hands the
choice back to the library's preference order rather than selecting VP8, so the
variable you meant to isolate is still being chosen for you. codecMimeType
forces one, and reports on onPublishWarning when the device does not
advertise it, rather than quietly publishing with something else and inverting
the conclusion of your test.
The symptom when these are wrong is specific and easy to misread as bandwidth: the
host log shows a full ladder at capture, then ReconfigureEncoder drops every
rung the moment a viewer joins and all the encoders actually run. Raising the
capture resolution makes it worse — it gives the encoder more to throw away.
Check is_hardware_accelerated in the host log before suspecting the network.
onStreamInterrupted is not a disconnect. Show a spinner and wait — tearing the
session down here turns a two-second blip into a lost broadcast.
onClosed carries why:
| Reason | Meaning |
|---|---|
replaced |
the same identity opened a newer session elsewhere |
kicked |
removed by a moderator |
roomClosed |
the host or the service ended it (onRoomClosed says why: quota-exhausted when the funds ran out) |
gaveUp |
reconnection was attempted and abandoned |
left |
you called close() |
Project media settings #
What a project may publish comes with the join: client.mediaPermissions
(audio and video on or off, the largest video quality, whether 60 fps is
allowed and up to which size, whether HD audio is allowed, how many videos one
participant may receive) and client.billingMode (BillingMode.legacy minutes
or the VoltKit wallet). Offer only these in your UI. The server enforces them
whatever a client sends; the SDK follows them so it never sends what would be
refused, and reports what it changed with the server's own code:
| Request | SDK behaviour | Code |
|---|---|---|
a kind the project switched off (publish/publishStream with such a track or constraint, publishScreen without video, publishMusic without audio) |
refused before any device opens or anything is produced | media-disabled |
setAudioQuality(AudioQuality.hd) without HD |
refused | audio-quality-not-allowed |
HD audio in publish without HD |
sent as standard, warning | audio-quality-not-allowed |
| 60 fps where no 60 fps mode is allowed | sent at 30, warning | frame-rate-not-allowed |
| a 60 fps size that is not enabled | the largest enabled 60 fps size, warning | video-quality-not-allowed |
| a 30 fps quality above the maximum | capped silently (the server does the same) | – |
| more videos than the project lets one participant receive (wallet billing) | the next waits until one ends, warning | video-subscription-limit |
| the camera or device cannot keep 60 fps | sent at 30, warning | frame-rate-fallback |
Warnings arrive on onError as RpcErrors and on onPublishWarning as
'<code>: message'. A project's media modes narrow wallet sessions only;
sessions billed in minutes keep every mode their settings allow. Older servers
send no media settings, and the project's video policy (videoPolicy) applies.
Publishing #
await client.publish(); // sensible defaults
await client.publish(constraints: {...}); // or your own
await client.publishStream(existingStream); // or a stream you already have
await client.setMicEnabled(false);
await client.setCamEnabled(false);
await client.unpublish();
Video is published as simulcast layers (by default 360p and 720p; see Video quality). The server picks one per viewer from their available bandwidth — you do not manage that. At 720p and 480p viewers start on the top layer and move down only if their connection cannot carry it.
Moderation #
await client.raiseHand();
await client.requestMute(identity); // moderating roles
await client.promote(identity, Role.cohost);
await client.kick(identity, 'spam');
await client.endRoom(); // host only
Promotion makes the promoted client reconnect. Publishers live on the origin node so their producers stay local, and a viewer may be on a relay. The SDK handles this: it receives the new role's token, reconnects, and rebuilds media. Your UI should expect a brief interruption for that participant, not an error.
Chat #
Every room has a chat channel of the same name. StreamClient carries it on the
connection it already holds, so there is nothing more to connect:
client.onMessage.listen((m) => setState(() => messages.add(m)));
client.onMessageDeleted.listen((e) => setState(() => messages.removeWhere((m) => m.id == e.id)));
await client.sendMessage('hello everyone');
await client.sendMessage('psst', to: 'user-42'); // direct: only the two of you see it
await client.sendMessage('', type: 'reaction', // delivered, not stored
metadata: {'emoji': 'heart'}, ephemeral: true);
final older = await client.getMessages(limit: 50, before: messages.first.id); // page backwards
await client.setTyping(true); // safe on every keystroke
final state = await client.getChatState(); // members, and whether you are muted
Your own message comes back on onMessage like everyone else's. Pass a
clientMsgId to match it to one you rendered optimistically.
text is delivered exactly as it was sent. Render it with Text, never as
HTML or markup.
Moderators — hosts, co-hosts and moderators — can also do this:
await client.deleteMessage(messageId); // anyone's; everyone else, only their own
await client.muteChat('user-42'); // survives that user reconnecting
await client.muteChat('user-42', muted: false);
A muted sender gets RpcError with code chat-muted; a client sending too fast
gets rate-limited. Both are worth showing to the user rather than retrying.
Chat without media #
An audience watching a broadcast has no reason to join a room. ChatClient
opens a connection that carries chat and nothing else, into the same
conversation:
// { token, wsUrl } from your backend: POST /v1/chat/{channel}/tokens
final chat = ChatClient(wsUrl: wsUrl, token: token);
chat.onMessage.listen(addMessage);
chat.onClosed.listen((reason) => showEnded(reason));
final joined = await chat.connect();
messages = joined.messages; // recent history, oldest first
await chat.sendMessage('first!');
// ...
await chat.close();
It has the same methods and events as the chat half of StreamClient, plus
onReconnected — the moment to call getMessages() and fill any gap.
Music #
A host can play music to the room without it going through the microphone. It
is published as a track of its own, labelled music, so the audience hears it
clean and everybody keeps separate control of it.
Hearing it (every platform). A music track arrives on onTrack like any
other, with source == 'music':
client.onTrack.listen((t) {
if (t.isMusic) {
musicRenderer.srcObject = t.stream; // its own renderer = its own volume
} else if (t.kind == 'video') {
videoRenderer.srcObject = t.stream;
}
});
// "Mute the music" for this viewer only, leaving the host's voice:
for (final track in musicStream.getAudioTracks()) {
track.enabled = false;
}
client.onTrackMuted.listen((m) {
if (m.source == 'music') setState(() => musicPlaying = !m.paused);
});
Playing it. Hand the SDK the audio as a MediaStream:
await client.publishMusic(musicStream);
await client.setMusicEnabled(false); // music off for the audience, mic untouched
await client.setMicEnabled(false); // mic off, music keeps playing
await client.stopMusic();
setMicEnabled and setMusicEnabled are independent, in both directions.
Music is HD and free: it always goes out at 128 kbps stereo, also in a project limited to standard audio, and the service never bills it. Sending or hearing music never changes anyone's rate; the microphone and video alone decide it.
Where musicStream comes from depends on the platform, and this is the part
to plan for:
| Platform | In-app music as a stream |
|---|---|
| Flutter Web | Yes. Play the file in an audio element and take its stream with the Web Audio API, as the browser SDK does. |
| Android, iOS | Not with the WebRTC plugin this package builds on (flutter_webrtc 0.9.x). It can capture the microphone and the screen, but it does not expose audio your app is playing as a track. |
On Android and iOS you therefore need a native audio source to get a clean music track — a platform channel that feeds decoded audio into WebRTC, or a plugin that does. Playing the song through the phone speaker and letting the microphone pick it up is the workaround to avoid: the echo canceller exists to remove exactly that sound. Until your app has such a source, mobile hosts can still receive music published from a web host, with the separate controls above.
Broadcast #
A broadcast is one publisher and an audience of any size, delivered over LL-HLS and WebRTC by a CDN-friendly media server rather than by a room. Use it when the audience watches and does not talk back.
// stream.publish.whipUrl from your backend: POST /v1/streams
final publisher = BroadcastPublisher(whipUrl: whipUrl);
publisher.onState.listen((s) => setState(() => state = s)); // connecting, live, interrupted, failed
publisher.onWarning.listen(debugPrint);
await publisher.start(); // camera + mic; or start(stream: yours)
preview.srcObject = publisher.localStream;
publisher.setMicEnabled(false);
publisher.setCamEnabled(false);
await publisher.stop(); // off air, camera released
The whipUrl carries the publish token. Treat it like a password: fetch it
when the user taps "go live", and do not log it.
start() throws WhipError if the media server refuses. isRefused means the
URL itself was not accepted — expired, for another stream, or the account is
out of minutes — and a retry with the same URL will be refused again.
Video is offered as H.264 only. The media server repackages H.264 into HLS
untouched; any other codec makes it transcode every frame. A device with no
H.264 encoder still publishes, and onWarning says so.
Viewers need nothing from this package: play the stream's llhls URL with any
HLS player (video_player handles it on iOS and Android).
BroadcastPublisher's HTTP exchange is tested here against a real server. The media half runs only on a device — check it on your own hardware against your deployment before you ship.
Session state, tokens and network quality #
client.connectionState; // SessionState.idle ... closed
client.onConnectionStateChanged.listen((c) => print('${c.previous.name} -> ${c.state.name}'));
client.onTokenExpiring.listen((_) async {
final expiresAt = await client.updateToken(await myBackend.mintRoomToken());
});
client.onNetworkQuality.listen((q) => showBars(q.level)); // excellent, good, poor, bad
client.onWalletLow.listen((w) => w.cleared ? hideRecharge() : showRecharge(w.remainingSeconds));
client.onError.listen((e) => log('${e.code}: ${e.message}'));
-
State.
SessionStatehas the five states every VoltKit SDK shares:idle(created),connecting,connected,reconnecting(the socket dropped and is being restored),closed(onClosedsays why). NamedSessionStateso it never clashes with Flutter's ownConnectionState. -
Tokens. The server refreshes the token it handed out while the account may be used.
updateToken(token)hands over one your backend minted for the same room and identity: on a live connection the server checks it like a join token (token-mismatchfor another room or identity,bad-tokenfor a bad one; the session keeps its token either way), otherwise it is kept for the next join.onTokenExpiringfires a minute before the held token expires when nobody refreshed it. -
Network quality comes from the media transports' statistics about every 3 s, and only while something listens: the worse round trip, packet loss over the interval in the worse direction, and the platform's bandwidth estimate.
Level Round trip below and loss below excellent150 ms 1 % good300 ms 3 % poor500 ms 8 % badotherwise An unknown value does not lower the level; bandwidth is reported, not graded.
-
Wallet. With wallet billing,
onWalletLowfires while the funds the project may use cover less than the grace period at the current burn, and again withcleared: trueonce a recharge or a lower burn lifts it (remainingSecondsis -1 when nothing limits the funds). At zero the room closes:onRoomClosedwithquota-exhausted.onUsageLimitstill covers minutes and usage caps. -
Errors. Every code an SDK can report is in one registry shared by every VoltKit SDK:
errorCodesmaps each code toErrorInfo(origin,terminal,retryable), andRpcError.infoandRpcError.isTerminalread it. Switch oncode, never on the message. A code this release does not know is passed through unchanged and is never terminal.
Reconnection #
Handled for you, and worth knowing what it does:
- exponential backoff with jitter, one chain at a time
- reconnects with the refreshed token the server pushes over the live socket, not the one you started with — the original expires in ~10 minutes, and a broadcast outlasts it
- republishes automatically, so a host survives its origin node dying
- gives up on terminal errors rather than looping: a revoked key or an ended room will refuse every attempt
- restores what you were publishing with the same video options and audio quality, and the receive quality you chose for each video
- after a reconnect,
participantsand the project's media settings come from the room as it is now; if you hold the token yourself, hand newer ones over withupdateTokenand the next reconnect uses them
disconnected is not the end of a session. onClosed is.
Two rooms at once (PK battles) #
Session replacement is scoped per room, so one identity can hold two rooms simultaneously — the shape of a PK battle, where two hosts each broadcast in their own room and viewers watch both.
final red = StreamClient(wsUrl: wsUrl, token: redToken);
final blue = StreamClient(wsUrl: wsUrl, token: blueToken);
await Future.wait([red.connect(), blue.connect()]);
// ... and close BOTH when the battle ends
Two clients means two sockets, two transport pairs and two ICE negotiations. On a mobile network that doubles the reconnect surface at the moment the audience is largest, so keep them independent: a reconnect on one must not disturb the other, and each must be closed before it is replaced.
Diagnosing "it just hangs" #
Pass onWire and every frame is logged in both directions:
final client = StreamClient(wsUrl: wsUrl, token: token, onWire: debugPrint);
-> #1 join
<- #1 join 6ms
-> #2 createTransport
<- #2 createTransport 8ms
-> #3 connectTransport
<- #3 connectTransport 12ms
-> #4 produce
<- #4 produce 9ms
This exists because two very different failures look identical from the app: the server never answered, or the client never asked. They point at opposite halves of the system, and guessing wastes days.
If publish() times out with produce-failed and there is no -> produce
line, the RPC never left the device. The request is assembled inside
mediasoup's _produce, which first awaits _handler.send(...) — the SDP and
peer-connection work — and only emits produce afterwards. A stall in that
handler means nothing is ever sent, and the server has no idea you wanted to
publish.
That handler runs inside a task queue which catches and prints exceptions
rather than surfacing them, so the underlying throw appears in the platform
log and nowhere else. Search it for a throw from unified_plan.run() or
send() before looking at the server.
If you do see -> produce with no <- line, that is genuinely server-side —
say so, because the orchestrator answers every produce, including invalid ones,
in about a millisecond.
Help #
Documentation: https://voltkit.in/docs/flutter-sdk. Questions and bug reports: contact@voltkit.in.
Testing #
flutter test
The signaling layer has no Flutter or WebRTC imports, so it is tested against a
real HttpServer WebSocket — reconnects, close code 4001, token refresh and
terminal errors are all exercised without a device. Audio quality, the
project's media settings, the live quality switches, connection state, token
updates, wallet notices and the roster are tested the same way, with a fake
send transport; network quality and statistics through their pure parsers.
Chat and the WHIP exchange are tested the same way.
Protocol parsing is tested against a captured live response rather than a hand-written fixture, because the failure mode of drift is a renamed field parsing as null and showing up as a black screen:
ORCH_URL=http://127.0.0.1:8080 API_KEY=sk_... ./tool/capture_fixtures.sh
On a real Android runtime, against a running stack — a browser hosts a room and an app built on this package joins it, publishes, subscribes and chats:
./tool/device_e2e/run.sh
How it is built #
Rooms use mediasoup's client protocol. That client is
included in this package (lib/src/mediasoup), adapted from
mediasoup_client_flutter
0.8.5 under its MIT licence. The published 0.8.5 does not build with current
flutter_webrtc, and a package on pub.dev cannot depend on a fork, so the
fixed copy ships here. Every change is marked VoltKit patch in the source:
- builds against
flutter_webrtc1.6 /webrtc_interface1.5 (encoding priority and RTCP CNAME types); - the transport waits for its peer connection before the first publish or subscribe (it could race it and fail with a null-check error);
- simulcast layers named with RIDs such as
r0parse (they were read as ints); - an encoding without a scalability mode is one layer, not a crash;
- the answer uses the header extension IDs the offer actually assigned (current libwebrtc rejects renumbering).
None of these could be seen without a device; tool/device_e2e/run.sh is the
test that found them, and test/vendored_mediasoup_test.dart pins them.
Viewer quality menu (0.5.0) #
await client.setReceiveQuality(track.producerId, '480p');
await client.setReceiveQuality(track.producerId, 'auto'); // recommended
Offer Auto (recommended) / 1080p / 720p / 480p. The helper selects the
highest actual layer fitting the tier, using RemoteProducer.videoLayers
(VideoLayer: spatialLayer, width, height). It works with two or three
layers, portrait cameras and screens. A 720p publisher cannot provide 1080p;
480p may select a 360p copy when that is its next available rung. An old
publisher without layout metadata reports quality-unavailable; automatic
reception remains available. Both this helper and setPreferredLayers remember
preferences by participant identity/source across reconnects, even when
producer or consumer IDs change. setPreferredLayers still takes a
consumer ID; the new helper takes a producer ID.
Screen sharing (0.5.0) #
Camera, screen, mic, music and optional screen audio have independent producers.
Only hosts/co-hosts can publish. setCamEnabled and setMicEnabled leave a
screen share alone; unpublishScreen() leaves camera/mic/music alone.
unpublish() and close() stop all publishing.
client.onScreenShareStarted.listen((stream) => screenPreview.srcObject = stream);
client.onScreenShareEnded.listen((_) => screenPreview.srcObject = null);
await client.publishScreen(
options: const ScreenPublishOptions(mode: 'detail', audio: true),
stream: displayStream, // optional: a screen capture the app already owns
);
await client.unpublishScreen();
screenSharing reports whether a capture is held; screenStream exposes it.
detail sends up to 1920×1080 at 15 fps, 2.5 Mbps plus a 600 kbps half-size
layer (~3.1 Mbps total). motion sends 30 fps at 6 Mbps plus 600 kbps (~6.6
Mbps total). Captures keep their native aspect ratio and are never upscaled.
The sender uses maintain-resolution for detail and maintain-framerate for
motion. A screen received on onTrack has source == 'screen'; granted
screen audio has source == 'screen-audio' and should have its own audio
renderer/volume, like music. Requesting audio does not guarantee it: when no
track is granted screen-audio-unavailable is reported on onPublishWarning
and video continues. Android's current flutter_webrtc capture provides video
only. Do not substitute the microphone for system audio.
A still-live screen is republished after reconnect, with the same profile.
Platform track onEnded automatically calls unpublishScreen; the application's
existing callback is preserved. flutter_webrtc 1.6.2 does not forward Android
MediaProjection's OS stop callback to Dart. Keep a visible Stop sharing
button wired to unpublishScreen; an app with a native projection-stop bridge
must call that method from its stop callback too. Do not infer that a static
slide has ended from a lack of new frames.
0.6.0 iOS preview: an extension template and native lifecycle plugin are
included, disabled by default. Native compilation and Apple-device acceptance
are unverified; the owner has no Apple hardware. Install/configure the extension
and explicitly pass ScreenPublishOptions(iosBroadcastEnabled: true) only when
integrating the preview. iosStartTimeout defaults to 60 seconds (positive,
at most four minutes). System stop, pause and resume use session-scoped lifecycle
events; onScreenSharePaused reports pause state. The native reader provides
video only, so audio: true warns and continues without shared audio. Camera/mic
rooms are unaffected, and caller-supplied screen streams remain caller-owned.
Read the complete iOS integration and validation guide.
Android foreground service #
The application owns a mediaProjection foreground service and its visible
notification. For example, using flutter_background, add these entries:
<!-- AndroidManifest.xml, outside <application> -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Inside <application>; the class is supplied by flutter_background -->
<service
android:name="de.julianassmann.flutter_background.IsolateHolderService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
Initialize the app's foreground-service plugin with an appropriate notification. Then pass its start/stop functions (or equivalent functions for your own native service). VoltKit requests user projection consent before invoking start, as modern Android requires:
// Import package:flutter_background/flutter_background.dart in your app.
await FlutterBackground.initialize(
androidConfig: const FlutterBackgroundAndroidConfig(
notificationTitle: 'Screen sharing',
notificationText: 'Your screen is being shared in this room',
),
);
await client.publishScreen(
options: const ScreenPublishOptions(mode: 'detail'),
startForegroundService: () async {
if (!await FlutterBackground.enableBackgroundExecution()) {
throw StateError('Foreground screen-capture service could not start');
}
},
stopForegroundService: () async {
await FlutterBackground.disableBackgroundExecution();
},
);
Missing callbacks are rejected with screen-share-service-required. If the
app passes stream, it owns permissions and its service, and stops both after
unpublishScreen; the SDK does not dispose caller-owned capture tracks.
Make the application's stop callback idempotent: it is also invoked if service
startup partially succeeds and then throws. A stop/close during projection
consent prevents a late foreground service or capture start. Cleanup failures
report screen-stop-failed on onPublishWarning; remaining capture tracks,
the stream and foreground-service cleanup are still attempted, and leaving the
room can finish. Keep the notification's stop action wired to unpublishScreen.
Windows, macOS and Linux #
Use desktopCapturer.getSources(types: [SourceType.Screen, SourceType.Window])
from flutter_webrtc, show a picker, and pass the source the user selects:
await client.publishScreen(
options: ScreenPublishOptions(mode: 'motion', sourceId: selectedSource.id),
);
Missing selection reports screen-share-source-required. macOS needs Screen
Recording permission in System Settings. Linux support depends on the display
server/desktop capture support in flutter_webrtc; validate your target desktop.
A capture that cannot produce measurable frames reports
screen-share-size-unavailable instead of advertising invented dimensions.
Other capture failures report screen-share-failed, permission denial can
report screen-share-denied, and starting twice reports screen-share-active.
See screen sharing for platform setup
and troubleshooting for connection,
quality and device problems.
Paid device recording (0.6.0) #
Recording is a separate prepaid add-on. Enable it in the project's Recording console after purchasing recording storage. Live quality remains independent: recording resolution, bitrate and frame rate come from the funded admin policy (default 360p, 15 fps, 400 kbps video / 48 kbps audio). Developers cannot request an arbitrary quality. Camera-on hosts/cohosts may record; viewers may not.
startRecording is currently an Android video-only preview, explicitly off
by default. It uses the existing local camera, creates bounded short MP4 files
and uploads them directly from the device to private R2. Native microphone
recording is not included in this preview. Android compilation passed; physical
capture is unverified. iOS native recording is unavailable; the independent iOS
screen-sharing preview does not add recording support.
await client.startRecording(
apiUrl: Uri.parse('https://api.voltkit.in/api/v1'),
androidCapturePreviewEnabled: true,
chunkMiB: 10, // optional: admin-allowed 5 or 10 MiB
onRecordingReady: (file) {
// Save file.id, file.playbackUrl and file.retainUntil in your app database.
// This URL opens the authenticated owner console; it is not a public file.
},
);
final files = await client.stopRecording(); // flush before unpublish/close
For an application-provided camera+microphone encoder, RecordingUpload.create
returns the authoritative RecordingPolicy. Encode a finalized MP4/WebM file
with those dimensions, bitrates, frame rate and duration limit, then call
uploadFile. Keep the file immutable until completion; use deleteAfterUpload
to remove temporary local files. Use abort on cancellation. This native file
uploader streams parts from disk with bounded concurrency rather than reading
whole recordings into RAM. Uploaded files are validated inside Cloudflare and
remain private until accepted. Room/session tokens authorize JSON API calls;
R2 PUTs carry only short-lived presigned credentials. Never put the project's
API secret or an R2 key in a device app.
final upload = await RecordingUpload.create(
apiUrl: Uri.parse('https://api.voltkit.in/api/v1'),
roomToken: roomToken,
);
// Encode a short file using upload.policy. Never accumulate unlimited files.
final file = await upload.uploadFile(encodedFile, deleteAfterUpload: true);
Completed recordings expire 90 days after upload completion. Save the
recording ID and expiry, and let your authenticated application backend mint a
short-lived media URL through /recording/upload/server/playback using its RTC
secret. That server must authorize its own end user before issuing playback.
Media streams from R2 through a Cloudflare Worker, never through VoltKit's VPS.
Playback uses a separate delivery quota; buying packages repeatedly adds to
balances. Recording upload quota includes retention and is consumed once; it
is not replenished by deleting files. See recording integration.