Skip to content

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.

QualityDefault 30 fps copiesHost video uploadTop layer at 60 fps
1080p960×540 at 2 Mbps · 1920×1080 at 6 MbpsAbout 8 Mbps9 Mbps
720p640×360 at 0.8 Mbps · 1280×720 at 3 MbpsAbout 3.8 Mbps4.5 Mbps
480p427×240 at 0.4 Mbps · 854×480 at 1.2 MbpsAbout 1.6 Mbps1.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, not ConnectionState: Flutter's widgets library already exports a ConnectionState, and an app importing both would not compile. The connectionState getter keeps the shared name.
  • updateToken hands 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-mismatch for another room or identity, bad-token for a bad one) and the session keeps its token either way. onTokenExpiring fires 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

  • onError carries warnings and refusals as RpcErrors with codes from the registry every VoltKit SDK shares: errorCodes maps each code to its ErrorInfo (origin, terminal, retryable), and RpcError.info and RpcError.isTerminal read it. kicked and billing-unavailable now end a reconnect at once. Every onPublishWarning message now starts with its code ('<code>: message').
  • onKicked (who and why) and onMustReconnect (the service is moving this client; handled for you) are new streams.
  • participants and remoteTracks are 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-fallback means 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.

StreamFires when
onTrack, onTrackEndedA remote camera or microphone is ready to render, or went away
onParticipantJoined, onParticipantLeftSomeone joined or left
onRoleChangedSomeone was promoted or demoted
onHandRaisedA hand went up or down
onForceMutedA moderator switched your microphone or camera off
onTrackMutedSomeone muted or unmuted themselves
onStreamInterruptedMedia stopped but the connection is fine: the publisher is reconnecting
onMessage, onMessageDeleted, onChatMuted, onTypingChat
onUsageLimitYour project ran out of minutes or hit a usage limit; the session ends at endsAt. Tell the user
onScreenShareStarted, onScreenShareEndedScreen capture is published or ends
onScreenSharePausedPrepared 0.6.0 iOS preview paused/resumed capture; carries a boolean
onPublishWarningA requested codec/frame rate could not apply, or display audio was unavailable
onErrorFrom 0.7.0: warnings and refusals as RpcErrors with registry codes, plus replaced and reconnect-gave-up
onWalletLowFrom 0.7.0: the credits the project may spend cover less than about a minute, or a recharge lifted that (cleared)
onRoomClosedFrom 0.7.0: the room ended, with the reason (quota-exhausted when the credits ran out); onClosed follows
onKicked, onMustReconnectFrom 0.7.0: a moderator removed you; the service is moving this client (handled for you)
onConnectionStateChangedFrom 0.7.0: connecting, connected, reconnecting, closed (SessionState)
onTokenExpiring, onNetworkQualityFrom 0.7.0: see Session state, tokens and network quality
onClosedThe 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.