Voxera Live Room Kit
A Flutter package for building video live-room experiences, powered by the Voxera Engine. It puts a host camera on a full-bleed stage with up to 3 invited co-publisher tiles — give it the required data and you get a fully interactive live room with no extra UI code.
The engine treats a live room as the live_stream product type: an unbounded,
seatless stage. There is no seat grid and no participant cap — anyone can
watch. The host invites co-publishers onto the stage (audio/video publish),
while the room owner promotes moderators. The kit only caps how many guest
tiles it draws (maxGuestTiles, default 3); the engine itself is
uncapped.
Roles are server-authoritative. Only the verified room owner joins as the publishing
host; everyone else joins asaudience(watch + chat). The engine clamps any non-owner's claimed role toaudience— a client cannot self-grant publish or moderation. Publishing and moderation are decoupled: being added to the stage grants publish only; being promoted toadmingrants moderation only (admins are never on camera).
Features
- Drop-in live-room UI — host camera + up to 3 co-publisher video tiles from required data alone.
- Stage flow — the host invites viewers onto the stage to co-publish;
viewers raise-hand (
stage/request) and the host adds them. No server-side queue — a request is a transient_stage_requestnotification to host/admins. - Persistent moderators — the owner can assign an identity before it joins;
the engine lists online and offline admins and grants
adminon the next join. - Host force-control — host can mute/unmute or stop a co-publisher's camera/mic, and remove them from the stage.
- Media controls — microphone & camera with reactive state (Bluetooth-preferring audio routing).
- Real-time chat — data-channel messages with batching and dedup.
- Reconnection — tiered: light sync (<15s), full sync (<60s), default force-exit (>60s).
- Minimize / PiP — minimize to a floating overlay; optional Android OS Picture-in-Picture.
- Video effects — pairs with
voxera_effects_kitviaVoxeraLiveRoomConfig.buildVideoEffectsProcessor(a video track processor) for live filters & beauty effects. - PK battles — two hosts, split screen, score bar, and a start sheet. Points stay on your backend; the kit publishes score deltas for animations.
Getting started
Add the dependency:
dependencies:
voxera_live_room_kit: ^2.1.2
Audio engine setup (required)
The native media engine is built once per process, and the first thing
that touches it decides its audio profile. The live kit calls this itself.
If your app touches WebRTC before the first room opens, call it first in
main():
import 'package:voxera_media_client/voxera_media_client.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await VoxeraMediaClient.initializeForRoom();
runApp(const MyApp());
}
That keeps echo cancellation on. Viewers stay on a media session. Publishing
a microphone uses the communication session and the loudspeaker. Do not call
initialize(mediaAudioProfile: true) — that turns echo cancellation off for
the rest of the process. If you ever see
[VoxeraMediaClient] the media engine was already initialized in the console,
something ran before this line; report it to us with the log.
Credentials — pass your appKey (no backend required)
Ship
appId+appKey(the project's publishable app key, from the Voxera console). The kit mints tokens directly from the engine withX-App-Key; the engine signs the returned per-useruser_tokenwith the projectserver_secretserver-side, so the secret never ships in the app. The kit uses thatuser_tokenas theAuthorization: Bearerfor all in-room calls. No token server to stand up.appKeycan't sign tokens or call the S2S API and rotates on its own.
import 'package:voxera_live_room_kit/voxera_live_room_kit.dart';
VoxeraLiveRoom(
appId: '<voxera-app-id>',
appKey: '<voxera-app-key>', // publishable; the kit mints tokens itself, no backend needed
userId: 'user123',
userName: 'Jane',
roomId: 'room456',
roomOwnerId: 'owner789',
// Identities the OWNER will promote to `admin` (moderators) once they join.
// Optional. The engine clamps non-owners to `audience`, so these are only
// honored because the owner promotes them server-side after join.
adminIds: const {'mod1', 'mod2'},
config: const VoxeraLiveRoomConfig(maxGuestTiles: 3),
);
The kit defaults token and in-room requests to https://stream.zavryn.online.
Override tokenBaseUrl and engineBaseUrl only for a different deployment.
Fast first join
Warm reusable DNS/TLS, device-ID, and RTC prerequisites during app startup or before the user opens the room list. This does not mint a token, claim a session, request permissions, or turn on the camera/microphone:
await VoxeraRoomController.warmUp(
tokenBaseUrl: 'https://stream.zavryn.online',
);
For the shortest tap-to-room path, start token generation when navigation
begins and pass the same future to tokenProvider. The room still validates
and adopts the server-issued user token before any RTC connection:
final controller = VoxeraRoomController()
..initApi(appId: appId, appKey: appKey);
final token = controller.generateToken(
identity: userId,
roomName: roomId,
roomOwnerId: roomOwnerId,
name: userName,
role: userId == roomOwnerId ? 'host' : 'audience',
);
VoxeraLiveRoom(
controller: controller,
tokenProvider: () => token,
appId: appId,
appKey: appKey,
userId: userId,
userName: userName,
roomId: roomId,
roomOwnerId: roomOwnerId,
);
The live room is
type: live_stream. The kit mints its token withtype: live_streamand sends no seat parameters (seat_count,seat_mode,host_seatare ignored for a live stream — the room is uncapped). The project must havelive_streamin itsenabled_typesin the Voxera console, otherwisePOST /api/v1/tokenreturns403 "Type 'live_stream' is not enabled for this project". The sameappId/appKeyworks for every product type the project has enabled — the type is a per-request field, not a separate credential.
See the exported API in
lib/voxera_live_room_kit.dart — VoxeraLiveRoom,
VoxeraRoomController, VoxeraStageApi, VoxeraMediaController, VoxeraChatController, and
the room/role/participant models.
Room messaging (engine-batched chat, gifts and likes)
When the engine offers it for your project (room_messaging in the token
response), the kit sends chat, app messages and likes through the engine's
room socket. The engine orders them (app messages, then host/admin chat, then
chat), caps visible chat per room and batches everything into one frame per
room per tick, so a busy room stays readable and light on the network. Your
code does not change: messages still arrive on dataStream with the same
payloads.
// true while chat / gifts / likes go through the engine
controller.roomMessagingActive.addListener(() { /* ... */ });
final ok = await controller.sendRoomMessage({'type': 'gift', 'gift_id': 7});
if (!ok) { /* the engine refused it: too fast or the room is busy */ }
controller.sendLike(); // taps are summed for you
controller.likeCount.addListener(() { /* room total */ });
controller.likeStream.listen((n) { /* animate n likes */ });
If the project does not use it, or the socket is down, chat and app messages
use the data channel exactly as before and sendLike() returns false.
Stage & roles
A live room has four server-authoritative roles:
| Role | Publishes | Moderates | How you get it |
|---|---|---|---|
host |
✅ | ✅ | The verified room owner (userId == roomOwnerId). Force-upgraded by the engine. |
guest |
✅ | ❌ | Host/admin invites you onto the stage. |
admin |
❌ | ✅ | The owner promotes you (moderate-only, never on camera). |
audience |
❌ | ❌ | Default for everyone non-owner — watch + chat. |
Going on stage (co-publish). A viewer raises their hand
(POST /api/v1/rooms/:name/stage/request), which fires a transient
_stage_request data message to the host/admins (there is no persisted queue).
A host/admin then adds them (.../stage/add → role becomes guest, publish
granted). Removal (.../stage/remove) or self step-down (.../stage/leave)
sends them back to audience. The current publisher set is
GET /api/v1/rooms/:name/stage → { members: [{ identity, name, role }] }, and
roster changes broadcast a _stage_update data message. These endpoints are
live_stream-only (a seated room returns 400).
The kit surfaces the stage through VoxeraRoomController — inviteToSpeak,
requestToSpeak, approveSpeakerRequest, rejectSpeakerRequest — and the raw
endpoints via controller.stageApi (VoxeraStageApi).
Promoting a moderator. Use the owner-only persistent roster APIs; the target does not need to be in the room:
await controller.assignAdmin(targetIdentity: id, name: 'Moderator');
final admins = await controller.listAdmins(); // each row has isOnline
final offline = await controller.listAdmins(presence: 'offline');
await controller.revokeAdmin(id);
adminIds / its resolver callbacks remain as a compatibility path where the
owner promotes configured identities after they join. In a stage room admin
grants moderation but not publish, and the engine refuses (409) an attempt
to add an admin to the stage—the capabilities stay disjoint.
maxGuestTilesis a UI-only cap. It sizes the on-screen tile strip (host + N co-publishers); the engine stage itself is unbounded. There is no seat grid — the host video is the full-bleed background and co-publishers are floating tiles above it.
PK battles
Turn the prebuilt UI on with pkConfig. null leaves PK off.
VoxeraLiveRoomConfig(
pkConfig: VoxeraPkConfig(
loadOpponents: () => api.liveRooms(), // raw roomName + hostName
sideAColor: const Color(0xFFFF4D6D),
sideBColor: const Color(0xFF4D7CFF),
scoreBarBuilder: (slot) => MyScoreBar(battle: slot.battle),
),
foregroundWidget: const MyGiftTray(),
)
The room owner gets a PK button on the built-in controls bar: Challenge a room (from loadOpponents) or Random match. roomName on each opponent is the raw room name passed to connect, not the prefixed SFU name. An app that replaces controlsBarWidget opens VoxeraPkStartSheet.show itself.
The engine owns the clock, the winner, and the score. The kit has no score API — a client that could post points could rig the battle. Your backend charges the gift, then posts the points to POST /api/v1/pk/:battleId/score with X-App-Secret and { "side": "a" | "b", "points": n } (or host_identity instead of side). The engine does not de-duplicate these posts: a retried request counts again, so your backend must post each gift once (for example, record the gift id before posting). A gift in a room scores that room's side.
Bind the fly-in to controller.pkController.scoreTick (deltaA / deltaB). The bar still reads battle.scoreA / battle.scoreB. The first snapshot after join does not emit a tick.
Replace one piece with a slot builder (scoreBarBuilder, countdownBuilder, resultBuilder, hostPaneBuilder, battleActionsBuilder, inviteDialogBuilder). Set showBattleOverlay: false to replace the whole layout and still render each host with cameraTrackFor.
Config
VoxeraLiveRoomConfig controls the live experience. Notable fields:
maxGuestTiles(default3) — how many co-publisher tiles to draw (UI cap only; the engine is uncapped).autoHostCamera(defaultfalse) — publish on connect vs. self-preview → "Go Live".showGoLiveButton(defaulttrue) — render the built-in "Go Live" button during host preview, or let the app driveVoxeraMediaController.goLive.frontCameraOnJoin/mirrorLocalVideo/hostFit/guestFit— camera & video-fit options.videoQuality(defaultVoxeraVideoQuality.hd, 720p) — the video publish quality for the local camera (capture resolution + publish bitrate/framerate). Tiers:low(360p ~450 kbps),sd(540p ~800 kbps),hd(720p ~1.7 Mbps),fullHd(1080p ~3 Mbps). Applied at connect; also settable headlessly viaVoxeraRoomController.setVideoQualitybeforeconnect.buildVideoProcessor— a video track processor factory for live filters & beauty effects.buildVideoEffectsProcessor— the same hook with aVoxeraVideoEffectsContext(provider, DeepAR catalog, device class). When set, it is used instead ofbuildVideoProcessor. A DeepAR project fetches its license key and effect list after join; the filters button appears only when the signed token enables DeepAR on this platform.- Section/tile overrides —
headerWidget,messagesWidget,controlsBarWidget,hostTileBuilder,guestTileBuilder,guestTileFooterBuilder,onStageTap. pkConfig— PK split layout, start sheet, and slot builders.nulldisables PK. See PK battles.
Requirements
- A Voxera Engine token endpoint, with
live_streamin the project'senabled_types. - Camera & microphone runtime permissions (handled via
permission_handler).
Room music
controller.music plays a song from this phone to the room. VoxeraLiveRoomConfig.roomMusicMode picks how:
VoxeraRoomMusicMode.track(default): the song goes on a second track,room-music. Listeners can mute the music alone.VoxeraRoomMusicMode.aux: the song is mixed into the microphone after echo cancellation (one uplink, about 31 MB/h per listener at 48 kbps instead of about 50). The mic button then mutes the voice only; the song keeps going. A host mute silences both. Listeners cannot mute the music alone. See the media client'sdoc/room-music-aux-mix.md.
final music = controller.music;
await music.start(path: cachedSongFile); // a downloaded server song works the same way
music.setPublishVolume(0.8); // what the room hears
music.setPlayoutVolume(0.5); // this phone's speaker
await music.pause(); await music.resume(); await music.seek(const Duration(seconds: 30));
music.onFinished.listen((_) { /* next song */ });
await music.stop();