SDKs
Flutter SDK for iOS and Android
Use the voltkit_rtc package in a Flutter app: join a VoltKit RTC room, publish camera and microphone, add chat, and go live with a broadcast.
voltkit_rtc is the Flutter package for VoltKit RTC. It has the same vocabulary as the JavaScript SDK: rooms, chat and broadcast publishing.
Install
flutter pub add voltkit_rtc
import 'package:voltkit_rtc/voltkit_rtc.dart';
Add the camera and microphone permissions your platform requires (NSCameraUsageDescription and NSMicrophoneUsageDescription on iOS; CAMERA and RECORD_AUDIO on Android).
Join a room
Your backend mints a token and sends the app token and wsUrl (see Authentication). The API secret never reaches the device.
final client = StreamClient(wsUrl: wsUrl, token: token);
// Wire events before connecting: tracks that are already
// being published arrive during connect().
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 and microphone
}
Video quality (SDK 0.5.0 and later). 1080p, 720p and 480p are included on every plan, with up to 60 fps.
await client.publish(video: const VideoPublishOptions(
quality: VideoQuality.p1080,
frameRate: 30, // default; 60 is opt-in
));
client.videoPolicy.maxQuality; // defaults to p1080
client.videoPolicy.maxFrameRate; // defaults to 60; admin restrictions can lower it
client.onPublishWarning.listen(showNonBlockingNotice);
VideoPublishOptions keeps the Flutter phone profile: two simulcast copies, hardware H.264 when advertised, and MAINTAIN_RESOLUTION. A desktop app can request layers: 3; a picture too small for three copies sends fewer. Portrait captures keep their orientation. Set codecMimeType: 'video/VP8' for VP8; an unavailable forced codec reports a warning rather than silently claiming it was used.
| Quality | Default 30 fps copies | Host video upload | Top layer at 60 fps |
|---|---|---|---|
| 1080p | 960×540 at 2 Mbps · 1920×1080 at 6 Mbps | About 8 Mbps | 9 Mbps |
| 720p | 640×360 at 0.8 Mbps · 1280×720 at 3 Mbps | About 3.8 Mbps | 4.5 Mbps |
| 480p | 427×240 at 0.4 Mbps · 854×480 at 1.2 Mbps | About 1.6 Mbps | 1.8 Mbps |
Audio is additional. frameRate: 60 runs only the top copy at 60; lower copies stay at 30 fps. The older maxFramerate spelling remains valid. topBitrate remains an explicit override.
Viewer quality menu
await client.setReceiveQuality(track.producerId, '480p');
await client.setReceiveQuality(track.producerId, 'auto'); // recommended
Offer Auto (recommended) / 1080p / 720p / 480p. It selects an actual available copy from the producer's videoLayers, including portrait and screen tracks. A 480p selection may receive a 360p copy, or 540p when that is the smallest copy available. Older publishers without layer metadata support Auto; fixed choices report quality-unavailable. Preferences are remembered by participant/source across reconnects, even if IDs change. The lower-level setPreferredLayers(consumerId, spatial, temporal) still takes the consumer ID.
Video quality explains adaptive selection, data use and the complete ladder. For soft or stuttering video, start with device overload and slow upload.
await client.setMicEnabled(false);
await client.setCamEnabled(false);
await client.unpublish();
Always await client.close() when leaving the screen. A client that is dropped without closing keeps its reconnect loop running.
Screen sharing
SDK 0.5.0 publishes a screen beside the camera:
client.onScreenShareStarted.listen((stream) => screenPreview.srcObject = stream);
client.onScreenShareEnded.listen((_) => screenPreview.srcObject = null);
await client.publishScreen(
stream: displayStream, // capture already acquired by the application
options: const ScreenPublishOptions(mode: 'detail', audio: true),
);
await client.unpublishScreen();
detail sends up to native 1080p at 15 fps (2.5 + 0.6 Mbps); motion sends 30 fps (6 + 0.6 Mbps). Screen video arrives as source == 'screen', and granted display audio as source == 'screen-audio'. Only hosts/co-hosts publish. screenSharing and screenStream expose local state. Camera and microphone switches leave the screen alone; unpublishScreen() leaves camera/mic/music alone.
Android requires a MediaProjection foreground service. When the SDK acquires capture, pass startForegroundService and stopForegroundService callbacks; it requests consent before starting the service. On Windows/macOS/Linux, show a desktopCapturer.getSources picker and pass the selected sourceId in ScreenPublishOptions. Caller-supplied streams remain caller-owned.
Prepared 0.6.0 iOS preview: the repository includes a Broadcast Upload Extension template and native lifecycle plugin. It requires app-specific Xcode target/signing/App Groups setup and explicit ScreenPublishOptions(iosBroadcastEnabled: true); iosStartTimeout defaults to 60 seconds. onScreenSharePaused reports system pause/resume. Native compilation and Apple-device acceptance are unverified, so this preview is disabled by default. Ordinary iOS camera/mic rooms remain available. Read iOS setup and the package's doc/IOS-SCREEN-SHARING.md before integrating it.
Read Screen sharing for the Android manifest and callback recipe, desktop setup, audio limitations, and platform stop handling.
Moderate
await client.raiseHand();
await client.requestMute(identity); // hosts, co-hosts and moderators
await client.promote(identity, Role.cohost);
await client.kick(identity, 'spam');
await client.endRoom(); // host only
Chat
Chat rides the room's own connection:
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 message
final older = await client.getMessages(limit: 50, before: messages.first.id);
For an audience that only watches, ChatClient opens a connection that carries chat and nothing else:
final chat = ChatClient(wsUrl: wsUrl, token: token); // from POST /v1/chat/{channel}/tokens
chat.onMessage.listen(addMessage);
chat.onReconnected.listen((_) => chat.getMessages(limit: 50)); // fill any gap
final joined = await chat.connect(); // joined.messages: recent history
await chat.sendMessage('first!');
Render text with a Text widget. It is delivered exactly as it was sent. More in Chat.
Music
A host's in-app music arrives as its own track, so your app can give it a separate volume:
client.onTrack.listen((t) {
if (t.isMusic) musicRenderer.srcObject = t.stream;
});
publishMusic, setMusicEnabled and stopMusic publish music next to the microphone, with independent switches. Where the music stream comes from differs by platform; read Music before you plan it for Android or iOS.
Publish a broadcast
BroadcastPublisher sends camera and microphone to the whipUrl your backend got when it created the stream (see Live broadcast):
final publisher = BroadcastPublisher(whipUrl: whipUrl);
publisher.onState.listen((s) => setState(() => state = s)); // connecting, live, interrupted, failed
publisher.onWarning.listen(debugPrint);
await publisher.start();
preview.srcObject = publisher.localStream;
// ...
await publisher.stop(); // off air, camera released
start() throws WhipError. When isRefused is true the URL was not accepted and you need a new one from your backend. Treat whipUrl like a password: it carries the publish token.
Viewers need nothing from this package. Play the stream's LL-HLS URL with any HLS player, such as video_player.
New in 0.7.0
voltkit_rtc 0.7.0 is on pub.dev (flutter pub add voltkit_rtc). Its unit tests pass; run the live switches below on your target phones before you ship them. Every 0.6 call works unchanged, and it works with older services too. It follows the same contract as the JavaScript SDK 0.7.0 and pairs with the VoltKit Wallet.
Audio quality
The microphone goes out as standard audio (mono Opus, declared at 64 kbps, sent at up to 60 kbps) unless you ask for HD (stereo Opus at up to 128 kbps). With the VoltKit Wallet the two are billed as standard and 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
Switching re-creates the microphone's track on the server: listeners hear a short gap, and a muted microphone stays muted. Music is a separate 128 kbps stereo track and is not affected: it is free, allowed in a project limited to standard audio, and never changes the rate.
Project media settings
client.mediaPermissions describes what the project may publish (audio and video on or off, the largest quality, 60 fps and up to which size, HD audio, how many videos one participant may receive), and client.billingMode whether the session is billed in minutes (BillingMode.legacy) or from the VoltKit Wallet. Offer only these in your interface. The SDK never sends what the project refuses: a kind that is switched off is refused with media-disabled before any device opens; HD audio, 60 fps or a 60 fps size the project does not allow goes out as the most it allows, with a warning carrying the server's code. A wallet session waits for a free video place instead of failing (video-subscription-limit). See Errors.
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.videoFrameRate; // 30 or 60
final stats = await client.getVideoStats(); // the camera's layers: size, fps, bytes, limitation
Each switch replaces the camera's track on the server, so viewers see a brief pause; a camera that is off stays off, and the screen, microphone and music are untouched.
Session state, tokens and network quality
client.connectionState; // SessionState.idle, connecting, connected, reconnecting, 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
- The state type is
SessionState, notConnectionState: Flutter's widgets library already exports aConnectionState, and an app importing both would not compile. TheconnectionStategetter keeps the shared name. updateTokenhands over a token your backend minted for the same room and identity; on a live connection the service checks it like a join token (token-mismatchfor another room or identity,bad-tokenfor a bad one) and the session keeps its token either way.onTokenExpiringfires about a minute before the held token expires, only when nobody refreshed it.- Network quality uses the same levels as the JavaScript SDK, about every 3 seconds while something listens.
Wallet notices
client.onWalletLow.listen((w) => w.cleared ? hideRecharge() : showRecharge(w.remainingSeconds));
client.onRoomClosed.listen((reason) => showEnded(reason)); // quota-exhausted when the credits ran out
With the VoltKit Wallet, onWalletLow fires while the credits the project may spend cover less than about a minute at the current rate, and again with cleared once a recharge lifts it (remainingSeconds is -1 when nothing limits the funds). At zero the room closes: onRoomClosed with quota-exhausted, then onClosed.
Errors and room state
onErrorcarries warnings and refusals asRpcErrors with codes from the registry every VoltKit SDK shares:errorCodesmaps each code to itsErrorInfo(origin, terminal, retryable), andRpcError.infoandRpcError.isTerminalread it.kickedandbilling-unavailablenow end a reconnect at once. EveryonPublishWarningmessage now starts with its code ('<code>: message').onKicked(who and why) andonMustReconnect(the service is moving this client; handled for you) are new streams.participantsandremoteTracksare kept current from the room's events;getRoomState()reads the room as it is now.- A project that does not allow 60 fps now warns
frame-rate-not-allowed;frame-rate-fallbackmeans only that the camera, device or upload cannot keep 60 fps.
Events
Every event is a broadcast Stream, safe to listen to more than once.
| Stream | Fires when |
|---|---|
onTrack, onTrackEnded | A remote camera or microphone is ready to render, or went away |
onParticipantJoined, onParticipantLeft | Someone joined or left |
onRoleChanged | Someone was promoted or demoted |
onHandRaised | A hand went up or down |
onForceMuted | A moderator switched your microphone or camera off |
onTrackMuted | Someone muted or unmuted themselves |
onStreamInterrupted | Media stopped but the connection is fine: the publisher is reconnecting |
onMessage, onMessageDeleted, onChatMuted, onTyping | Chat |
onUsageLimit | Your project ran out of minutes or hit a usage limit; the session ends at endsAt. Tell the user |
onScreenShareStarted, onScreenShareEnded | Screen capture is published or ends |
onScreenSharePaused | Prepared 0.6.0 iOS preview paused/resumed capture; carries a boolean |
onPublishWarning | A requested codec/frame rate could not apply, or display audio was unavailable |
onError | From 0.7.0: warnings and refusals as RpcErrors with registry codes, plus replaced and reconnect-gave-up |
onWalletLow | From 0.7.0: the credits the project may spend cover less than about a minute, or a recharge lifted that (cleared) |
onRoomClosed | From 0.7.0: the room ended, with the reason (quota-exhausted when the credits ran out); onClosed follows |
onKicked, onMustReconnect | From 0.7.0: a moderator removed you; the service is moving this client (handled for you) |
onConnectionStateChanged | From 0.7.0: connecting, connected, reconnecting, closed (SessionState) |
onTokenExpiring, onNetworkQuality | From 0.7.0: see Session state, tokens and network quality |
onClosed | The session is over and will not recover |
Reconnection
Handled for you: a dropped connection is retried with backoff, the session is rejoined, and your camera, microphone, music and still-live screen are published again. onClosed tells you why a session ended for good, including replaced (the same identity joined from another device) and gaveUp.
Help
Questions and bug reports: contact@voltkit.in. Error codes are listed on the Errors page, and picture, sound and connection problems on Troubleshooting.
Desktop builds and H.264
On Android and iOS, H.264 uses the phone's built-in hardware encoder. The Windows and Linux builds of flutter_webrtc include a software H.264 codec (OpenH264). If you ship desktop apps commercially, check the H.264 licence terms (Via LA) with your lawyer, or publish VP8 on desktop.
onParticipantMuted reports moderation locks as identity, kind and paused. Reconnect rosters carry muted, videoBlocked and pending request handMetadata.
Last updated . Something unclear or wrong? Tell us.