voxera_live_room_kit 2.1.2
voxera_live_room_kit: ^2.1.2 copied to clipboard
Real-time video live room for Flutter: host camera plus up to three guest tiles, go-live requests, host media control, chat, and minimize/PiP.
Changelog #
2.1.2 #
- The package homepage points to
voxerastream.com. Error links openvoxerastream.com/docs/errors(voxera_room_core1.1.1). The README names the real effects package,voxera_effects_kit. - Requires
voxera_media_client ^1.6.1andvoxera_room_core ^1.1.1.
2.1.1 #
- The kit reports its real version to the engine (the
X-Voxera-Kitheader and thevx_kitattribute). 2.1.0 reported 2.0.0, so the engine could hold back features meant for newer kits.
2.1.0 #
- Every failure has a code from the Voxera error table (voxera_room_core 1.1,
doc/errors.mdthere). A failed connect, microphone start or camera start prints one line such as[Voxera] E42901 RATE_LIMITED: Rate limit exceeded (retry in 30s) · https://voxera.app/docs/errors/42901 · req 3f2a…and is delivered onVoxeraErrors.stream. - Token errors are read from the engine's
status_code, so a ban and a service that is not enabled are told apart by code rather than by message text. Engines that send no code still work as before.VoxeraBannedException,VoxeraRateLimitedException,VoxeraServiceNotAvailableExceptionandVoxeraTokenExceptionare nowVoxeraErrors: each hascode,requestIdanddocsUrl. A suspended project (41001), a revoked session (41002), an expired credit lease (41003) and the concurrent-session cap (42902) areVoxeraServiceNotAvailableExceptions. onConnectErrorreceives theVoxeraError(the same object for the token refusals above, a converted one for media and network errors, with the original incause).- The built-in "Action failed" messages and the connect-error view end with the code, for example "Action failed. Please try again. (40902)".
VoxeraErrors.showCodesInUi = falsehides it. A failed in-room request is only noted: it is printed when the kit shows a failure, so the ones the kit treats as done (leaving a seat you are already off) stay quiet.
2.0.0 #
- Built on the new
voxera_room_core1.0.0, which now holds the code both room kits had as two copies: connection state, reconnect, session resume, connect budget, MOS, mute strategy, token response and refresh, the token and ban exceptions,VoxeraJoinFailure, data frames, messaging, room music, the mini overlay, minimize, PiP, prefetch andVoxeraNoiseCancellation. The kit re-exports it, so imports don't change; an app that uses both kits now gets one type for each of these instead of two. VoxeraRoomControllerimplementsVoxeraRoomSession, the interface both kits share (music, data frames, noise cancellation, minimize, PiP, leave, ...).VoxeraRoomSession.activeis the room the user is in, from either kit.- One mini overlay for both kits.
VoxeraMiniOverlayMachinerecords which room is minimized (minimizedSession), andcontroller.minimize.isMinimizingis now true only for that room, not for any controller while some room is minimized. Another room'sleave,closeordismissno longer hides its floating window. A room of the other kit stays in its window until this room connects.VoxeraMiniOverlayHost(builder:)shows whichever room is minimized and replaces per-kit overlay builders in the app. - Fix: closing a room screen while a different room was minimized left that screen's controller undisposed.
VoxeraLiveRoomConfig.effectstakes video-effects plug-ins (VoxeraVideoEffectsPlugin, e.g.VoxeraDeepArEffectsfromvoxera_effects_kit0.2) in priority order. The kit uses the first thathandlesthe session for the camera processor and the filters button, attaches the processor to the camera when the plug-in asks, reports DeepAR status, and releases it when the room is left, unless a newer room took the plug-in over.controller.setVideoEffectsPlugins(...)andvideoEffectsPlugindo the same by hand. With no plug-in serving the session,buildVideoEffectsProcessor,buildVideoProcessorandonEffectsTapapply as before.VoxeraVideoEffectsContextandVoxeraDeviceClassmoved tovoxera_room_core(re-exported).adminUnmutedStream, forVoxeraRoomSession. Live rooms have no admin mute, so it never fires.- Breaking:
VoxeraMinimizeControlleris generic (the controller'sminimizeis aVoxeraMinimizeController<VoxeraMinimizeConfig>),VoxeraMinimizeData.controlleris aVoxeraRoomSession,VoxeraMinimizeConfigimplementsVoxeraMinimizeOptions, andVoxeraRoomPrefetchis a typedef ofVoxeraSessionPrefetch<VoxeraRoomController>. Code that only uses these through the controller is unaffected. - Requires
voxera_room_core ^1.0.0.
1.9.0 #
- OS Picture-in-Picture state now comes from
voxera_media_client1.5 (VoxeraPictureInPicture.changes) while PiP is armed, so the app no longer needs aMainActivity.onPictureInPictureModeChangedoverride and a channel callingpip.setInPip.setInPipstill works for apps that keep that wiring; a repeated state is ignored. - Requires
voxera_media_client ^1.5.0.
1.8.0 #
- Fix:
leave()cleans up the stage even when the media connection is down (a leave during a reconnect). A guest steps off the stage and a host takes every guest off it; before, both were skipped unless connected, so tiles stayed up until the engine's participant-left cleanup and apps ran their own copy of the cleanup. The calls are REST with a 2 s limit._live_endedis still sent only while connected.seatController.removeFromStagetakes an optionalactorIdentity. - Credentials and hosts can be set once with
VoxeraKit.configure(appId:, appKey:, tokenBaseUrl:, engineBaseUrl:)fromvoxera_media_client1.4.VoxeraLiveRoom'sappId,appKey,tokenBaseUrlandengineBaseUrlare now optional and nullable;initApiandwarmUptake the same values as nullable. Each value comes from the argument, thenVoxeraKit.configure, then the built-in default. Code that read these widget fields as non-nullStringneeds a!or a default. - New
VoxeraLiveRoom.startPrefetch(...)mints the join token when the user taps a room and returns aVoxeraRoomPrefetch. Pass it to the widget's newprefetch:argument and the widget takes its controller and token. The request is the one the widget sends itself, so an app no longer copies it. The prefetch's token never throws (null means the widget mints its own); one no room takes is released withdispose(), which waits for the mint to settle.
1.7.0 #
- Fix:
leave()takes a host out of the PK random-match queue. Before, a host who left while waiting stayed queued on the engine (pkController.stop()only reset the local state) and could be paired into a battle for a live that had ended, so apps calledcancelRandomMatch()themselves. It is a REST call with a 2 s limit and runs whether or not the media room is still up. VoxeraRoomController.initApitakestokenBaseUrlfor the token host.baseUrlstill works as its deprecated name (tokenBaseUrlwins when both are passed). The audio kit'sinitApiusedbaseUrlfor the engine host; both kits now taketokenBaseUrlandengineBaseUrlwith the same meaning.- New
VoxeraRoomController.dataFrameStream, as in the audio kit: each message with its send id and the sender's identity from the transport. Before it was only reachable asroomManager.dataFrameStream. - New
VoxeraJoinFailure.classify(error)foronConnectError:rateLimited(with the engine'sretryAfter),unavailable,bannedortransient, plusisRetryable. The audio and live kits ship the same class, so an app no longer matches the exception types itself. - Removed the
audio_sessiondependency; no kit code imported it. A host app onaudio_session0.2 no longer needs an override. - Declares Dart >=3.7.0 / Flutter >=3.29.0 (was >=3.4.3 / >=3.0.0), which
voxera_media_clientalready required throughdevice_info_plus.
1.6.0 #
- Requires
voxera_media_client1.3.3 (was 1.3.0): turning a camera or microphone on or off on a track whose peer connection already closed no longer raises an uncaughtPlatformException(it was reported as a fatal crash), plus iOS noise cancellation and the iOS pod build fixes from 1.3.1 and 1.3.2. No API change for the kit. - New
VoxeraLiveRoomConfig.connectingPlaceholder(default null): a widget shown full-bleed to a viewer from the moment the room opens until the host's video is on screen, typically the live's cover image. Without it a viewer saw a dark gradient with a spinner, then the host's avatar, then the video. When the host's camera is off the placeholder gives way to the avatar 3 s after connect. It sits under the chrome, never takes touches, and hosts never see it. Also exported asVoxeraHostVideoPendingCover. - New
VoxeraLiveRoomConfig.showChromeWhileConnecting(default false): the header, messages and controls bar appear when the token is in instead of when the media connection is up. Anything the chrome sends before the connection is up must handle a not-yet-connected controller (the default chat drops it). - Faster first video:
mutedParticipantsandcameraOnParticipantsnow follow the room's track and participant events (published, unpublished, subscribed, unsubscribed, muted, unmuted, joined, left) instead of only the 2 s poll, and one refresh runs right after connect. Before, the first poll tick landed 2 s after connect, so a viewer saw the host's avatar for up to 2 s after the host's video track was already subscribed; PK panes and anything else readingcameraOnForhad the same delay. A burst of events is one refresh. The 2 s poll stays as the backstop and is still the only path that reconciles the local controls-bar mic/camera state. NewVoxeraRoomManager.mediaStateChangedStream. VoxeraLiveStagerebuilds less: the full-bleed host layer no longer listens toactiveSpeakers, seats or roles (a camera-off host avatar still follows the host's own speaking flag), and each guest tile rebuilds only when its own speaking or mute flag flips. A customhostTileBuilderkeeps the full listener set. Guest slots are keyed by occupant, so a reorder moves the renderer instead of recreating it.- Fix: guest tiles now receive mic-mute changes (
isMicMuted); the stage readmutedParticipantsbut never listened to it. VoxeraPkBattleOverlay: the 1 Hz countdown tick rebuilds only the countdown (a customcountdownBuilderis still called every second) instead of the whole overlay with both video panes and the score bar. Panes follow video track and camera state directly (they used to catch up on the next tick) and each pane sits in its ownRepaintBoundary. A customscoreBarBuilder,hostPaneBuilderorbattleActionsBuilderis now rebuilt on battle changes only, not every second.
1.5.1 #
- Fix:
VoxeraRoomController.music.startpoints the media client at the current controller's participant on every start. 1.5.0 kept the first controller's resolver (??=), so after the room was left and joined again an aux song failed against the disposed participant and the app fell back to theroom-musictrack.
1.5.0 #
- New
VoxeraLiveRoomConfig.roomMusicMode(VoxeraRoomMusicMode.trackby default) andVoxeraRoomController.music(VoxeraRoomMusic): start, pause, resume, seek, stop, publish volume, playout volume, position, duration,onFinished. VoxeraRoomMusicMode.auxmixes the song into the microphone after echo cancellation (voxera_media_clientRoomMusicAux). While a song plays the mic button is the voice gain: self mute keeps the track live and the song going and keeps the Android call mode; the track is muted the normal way once the song stops. A reconnect republishes a microphone the song needs.VoxeraLiveRoomConfig.auxMusicBitrate(48000 by default).- Requires voxera_media_client 1.3.0 (
RoomMusicAux).
1.4.0 #
- New
VoxeraLiveRoomConfig.earlyReconnectingState(default false), alsoVoxeraRoomController.setEarlyReconnectingState. With true,connectionStategoes toreconnectingat the media SDK's first reconnect attempt (RoomAttemptReconnectEvent) and back toconnectedonRoomReconnectedEvent. With false, as before, it changes only for a full reconnect (RoomReconnectingEvent). Measured with the audio room kit on a phone (Android 15) and the emulator: after a network cut the SDK started reconnecting 4-7 s later and the server dropped the user at about 20 s, but the kit saidconnectedfor 85-157 s after the cut. - The setting also turns on the media client's
RoomOptions.resilientReconnect: a camera or microphone that cannot be published again after a full reconnect is dropped instead of leaving the kit stuck inreconnecting, and a network that comes back without a connectivity callback is seen within 2 s instead of after up to 70 s. - With true, the seat controller's 60 s force exit (
onForceExit) counts from the cut, because it starts atreconnecting: a cut of more than about 60 s ends in a force exit, a shorter one in a stage resync when the room is back. - Requires
voxera_media_client1.2.0 (RoomOptions.resilientReconnect). - New
VoxeraLiveRoomConfig.noiseCancellation(VoxeraNoiseCancellation.offby default,standardorvoiceFocus): AI noise cancellation of the local microphone on the device (Android,VoxeraAudioProfile.speechonly; voxera_media_clientAudioCaptureOptions.noiseCancellation).standardremoves noise and keeps every voice;voiceFocusalso lowers voices far from the phone (a TV) by 20 dB while the user is quiet.VoxeraRoomController.setNoiseCancellationswitches it while the microphone is live, for a user-facing toggle;VoxeraRoomController.isNoiseCancellationSupported()tells whether to show it. The media diagnostics include its stats only while it is on. Needs voxera_media_client 1.2.0, which adds its native library to every app (about 2.4 MB per ABI, about 7 MB in a fat APK) even with noise cancellation off; an app can drop it withpackaging { jniLibs { excludes += ["**/libvoxera_nc.so"] } }. The modes act on the device's one microphone capture, so a PK cross-room publish gets them too.
1.3.0 #
- New
VoxeraLiveRoomConfig.silenceGate(VoxeraSilenceGate.offby default, orstandard): an open but silent microphone stops streaming room noise. A guest or host with the mic open in a normal room sent ~49 packets/s and ~23 kbps of payload (about 22 MB/h up, and ~20 MB/h down for every viewer) even with nobody talking; with the gate those pauses go out as Opus DTX (about one packet per 400 ms). Applies to the speech profile only, Android only for now;offleaves the microphone path unchanged. The gate acts on the device's microphone capture, so a PK cross-room publish of the same mic is gated too.VoxeraRoomController.setSilenceGate,VoxeraMediaController.silenceGateStats()and the audio diagnostics show the gate's open share, threshold and noise floor. Requiresvoxera_media_clientwithAudioCaptureOptions.silenceGate. - New
VoxeraLiveRoomConfig.adaptiveRed(VoxeraAdaptiveRed.offby default, orstandard): Opus RED for the local microphone, chosen at each publish from the packet loss this device measured. The quality reporter's readings are pooled over the last 2 minutes (lost / expected packets): this microphone's uplink once it was published in the session, before that the least lossy received stream (the others carry their senders' uplink loss). On at 2% or more, off below 0.5%; in between, or with fewer than about 1000 packets in the window (a mostly silent DTX mic), the previous choice holds. The choice and the loss are stored per media host (when they change, at most once a minute, and on leave; up to 24 h; a clock moved back invalidates them), so the next session's first publish starts from the path's last choice. RED (one redundant frame per packet) lets every listener recover a single lost packet. Measured on a phone (loss at the listener 2.9-8.5%): concealed audio while talking 2.80 / 5.65 / 7.17% -> 0.75 / 0.43 / 1.31%, concealment events per minute 80.5 / 154.2 / 189.7 -> 6.5 / 8.2 / 26.0; it sounded clearer with fewer cuts. It roughly doubles the payload of a talking stream (23.8 -> 49.4, 23.8 -> 48.9 and 20.7 -> 45.4 kbps; phone upload 22.5 -> 34 MB/h) for the speaker and every RED-capable listener, costs almost nothing in silence, and the packet count stays the same. A mic that is already published keeps its RED (no republish of a live mic). Speech profile only. The PK battle cross-room microphone (VoxeraPkControllerdual-publish) is not covered: it keeps the room defaults.VoxeraRoomController.setAdaptiveRed,VoxeraRoomController.redDecision(read-onlyVoxeraRedDecision),VoxeraMediaController.micRed(the RED of the mic as published) and the audio diagnostics show the choice. Without the quality reporter (no quality API or no user token) a debug warning is logged and the stored choice, or RED off, is used. Requiresvoxera_media_clientwithsetMicrophoneEnabled(audioPublishOptions:). - Room messaging over the engine WebSocket (engine Phase 2.1). When the token response carries
room_messaging(the project turned it on in the engine), chat (sendChatMessage), app messages (sendRoomMessage, e.g. gifts) and likes go through the engine's room socket instead of the data channel. The engine delivers app messages first, then host/admin chat, then chat, shows at most 20 chat lines per second per room, sends one frame per room per tick and sums likes into one counter. Incoming messages still arrive ondataStream/dataFrameStreamwith the same payloads;dataFrameStreamcarries the sender the engine verified. - Without
room_messaging(older engines, projects that did not turn it on) nothing changes: no socket is opened and everything stays on the data channel. - The socket reconnects on its own; while it is down chat and app messages use the data channel. The engine also copies every room message to the data channel for kits that do not know room messaging yet, and the kit keeps one copy of a message that arrives both ways. A device never receives its own message back.
- New:
VoxeraRoomController.roomMessagingActive,likeCount,likeStreamandsendLike().sendRoomMessagenow returnsFuture<bool>: false when the engine refused the message (too fast, room busy).VoxeraTokenResponse.roomMessagingcarries the engine field. - Every engine request now carries
X-Voxera-Kit: <package>/<version>(for examplevoxera_live_room_kit/1.2.1), and the kit adds avx_kitparticipant attribute with the same value to the attribute update it already makes after connect (no update of its own). The engine stores it with the participant and reports the share of users per kit version, so features that need a newer kit (the audio Mixer of engine Phase 2.4) can wait until enough users run one. Engines that do not know the header ignore it; nothing else in the requests changes. - New:
kVoxeraKitName,kVoxeraKitVersion,kVoxeraKitId(<package>/<version>),kVoxeraKitHeaderandkVoxeraKitAttribute. An app whose backend mints the token (tokenProvider) can forwardkVoxeraKitIdas theX-Voxera-Kitheader on that call.
1.2.1 #
- Require
voxera_media_client^1.0.12. From that version the media SDK waits for the first media path (ICE + DTLS) withTimeouts.peerConnectioninstead of the connection timeout. - A first join gives the media path at least 15s (
VoxeraConstants.rtcPeerConnectionTimeout), or the join's phase budget when that is larger. A network that blocks UDP has to reach the TURN relay over TLS, which regularly takes longer than the signaling phases; cutting it short failed the join, dialed again, and spent more of the user's TURN allocations. Reconnects keep the 10s media floor from 1.2.0. The backstop covers the longer phase (connectTimeout25s -> 33s for the floor budget). - The redial pacer is process-wide, so the leave-and-enter-again loop no longer resets it with every new controller.
VoxeraRateLimitedException.retryAftercarries the wait the engine asked for (Retry-AfterorRateLimit-Reset).VoxeraDeviceIdresolves through the media client's process-wideVoxeraInstallId, which it shares with the audio-room kit.
1.2.0 #
- Join timeout follows the network. Each connect phase used to get a fixed 7s; a slow join that was still making progress was cut and dialed again, which the server saw as a second session for the same person. The budget now starts from what this device needed before on the same media host (2.5x, at least 7s), grows on each retry, and stops at 20s. On a network that is fully down the first join now gives up after about 40s instead of about 24s (7s + 11.2s + 17.9s with the 1s and 2s pauses, against 3 x 7s before).
- The larger join budget applies to first joins only. After a join the media SDK's own reconnect timeouts stay at 7s: the SDK waits out that timeout when a restarted node refuses its resume, so a larger value would only lengthen that wait.
- A token the media node refuses (401/403) is no longer dialed two more times. The failure goes straight back to the caller, and the automatic rejoin mints a new token.
- While the media SDK retries a dropped session, the kit fetches a fresh token at the 3rd attempt (and the 6th), after a random 0-2s wait so that a room dropped by a node restart does not send every token request in the same second, and hands its URL and token to the remaining attempts. A node that restarted with a new key, or a room the engine moved to another node, is reached in seconds instead of after the SDK's ten attempts (about a minute), without leaving the room.
initApiwarms the token host (HEAD /warmz) and the last media host by default (autoWarmUp, default true). A token request that starts while that warm-up is in flight waits for it, at most 1.5s, and reuses its connection.- After a reconnect the stage is read once from
GET /stage, in addition to the room metadata._stage_updatemessages sent while the session was down are lost, and after a media node restart the room metadata starts empty.VoxeraSyncManager.startPeriodicSyncno longer polls (deprecated): the stage is pushed, not polled. - A healthy viewer posts a quality report every 5 minutes instead of every minute. Publishers keep 20s, and a degraded session still reports after 10s.
1.1.3 #
- Turn off per-track stats polling (
Track.statsMonitorEnabled = false) on connect. Nothing in the kit reads the stats events, and each camera, microphone and remote track cost a nativegetStats()every 2 s. The quality reporter keeps its own sampling. - Require
voxera_media_client^1.0.11, which keeps the Android silent-microphone check running with stats polling off.
1.1.2 #
- Default live video quality is
auto(sd, or low on a weak device).
1.1.1 #
- Add
VoxeraGuestPositionenum andVoxeraLiveRoomConfig.guestPositionto configure horizontal placement of floating guest video tiles (rightby default, orleft). - Fix minimize and PiP overlay navigation when unnamed dialogs or bottom sheets are open, preventing unintended bubble overlay over the active room and properly preserving room routes for restore.
- Update
voxera_media_clientdependency to^1.0.6.
1.1.0 #
- DeepAR AR filters. A project chooses Voxera beauty effects or DeepAR. The join token carries
videoEffectsProviderand a fail-closeddeeparflag. When DeepAR is on, the kit fetches the license key and effect catalog after join without delaying the connection, and keeps that catalog across token refresh. VoxeraLiveRoomConfig.buildVideoEffectsProcessorreceives aVoxeraVideoEffectsContext.buildVideoProcessorstill works and is used when the new factory is not set.- The filters button stays hidden on a DeepAR project until the token enables DeepAR for this platform, and on a Voxera-effects project when the beauty entitlement is off.
1.0.3 #
- Hosts can start a PK from the controls bar (challenge a live room or random match). Score changes publish
VoxeraPkScoreTickfor gift animations.VoxeraPkConfigslot builders replace the score bar, countdown, result, host pane, end control, and invite dialog. - Chat messages are capped at 500 characters, deduped, and removed locally if the send fails.
- Ban lists are scoped to the current room. Quality reports include video bitrate, fps, packet loss, and frozen frames.
1.0.2 #
- Align with voxera_media_client 1.0.4 and flutter_webrtc 1.6.2 so every Voxera room kit uses the same native WebRTC generation.
1.0.1 #
- The host writes the room admin roster with assignAdmin as soon as the admin list is known, and retries a failed grant so the first entry is not left without admin powers.
- Depend on voxera_media_client 1.0.2.
3.1.0 #
Staying on the stream is now the kit's job, not your app's.
Four days of production logs say the same thing in four different ways: when a session is interrupted, the kit hands the problem to the host app, and the host app does the only thing it can - try again, immediately, forever. Every item below is one of those loops moved inside the kit, where it can be paced, measured and stopped. The last one closes a gap this kit had and the audio kit did not: it reported no quality telemetry at all.
Reconnects back off instead of hammering - and stop exhausting TURN #
One user produced 100 session attempts in 45 seconds. The engine grants a user twelve TURN allocations; every attempt builds a new PeerConnection and claims one. The quota was gone in the first few seconds, and every attempt after that was refused before it could reach the room - the retry loop was the outage. This kit's own retry delay was a flat 500ms, which is the shape of that incident.
Re-establishing now follows a schedule: 1s, 2s, 4s ... capped at 30s, with
plus/minus 20% jitter so a fleet knocked offline by one SFU restart does not come
back in lockstep and re-create the outage. The schedule is enforced where the
dialing happens (VoxeraRedialPacer inside the room manager), so an app that calls
connect() in its own loop is paced too. Dialing a different room is a new
intent, not a retry - a viewer switching streams never waits.
- New:
VoxeraReconnectPolicy(the schedule) andVoxeraRedialPacer(the enforcement). - Removed:
VoxeraConstants.retryDelay. The delay is no longer a single number;VoxeraConstants.reconnectInitialDelay/reconnectMaxDelay/reconnectJitterFractiondescribe the schedule instead. If you referenced the old constant, this is the one line you need to change.
An expired user token is renewed once, and the refused call is replayed #
The engine answers an expired user token with 401 {"code":"token_expired"} and
WWW-Authenticate: Bearer error="invalid_token", error_description="expired".
The kit read neither and kept sending the dead token: 715 refusals for a single
user in one day, with every stage, role and moderation call in that window
failing silently while the stream looked fine.
Now: the refusal is recognised (body and header), the token is re-minted once, and the refused request is replayed with the fresh bearer. Concurrent failures share one mint rather than each triggering their own. A 401 that is not an expiry - a revoked token, the wrong project - is passed through untouched, because re-minting for those is a loop, not a fix.
The kit also renews ahead of time, at 80% of user_token_expires_in, which
matters most here: a broadcast runs far longer than a token's life.
- If your backend mints tokens: set
controller.tokenProviderso the kit has a way to get a fresh one. Without it, renewal is not possible and refusals surface exactly as they did before. - If you mint through
controller.generateToken(): nothing to do - the last request is replayed. VoxeraTokenResponse.userTokenExpiresIncarries the lifetime through (numbers or numeric strings).
Returning from the background resumes the session instead of evicting it #
The engine allows one session per identity. Re-joining the room you are already
in therefore kicks your own session out - DUPLICATE_IDENTITY, 374 times
in one day on a single project, each one a dropped stream for the user it
happened to.
connect() now recognises a join for the room already in hand: the existing
session gets up to 8 seconds to come back (keeping its PeerConnection and its
TURN allocation), and only if it does not is a fresh session dialed. A join for
a different room tears the old one down exactly as before. A session that ends
for good is detected immediately - nobody waits out the timeout for a session
that is already gone.
Quality telemetry, with latency_ms on every report #
This kit sent no quality reports at all, so a live stream was the one product surface whose quality dashboard was empty - "the stream is bad" arrived with no numbers attached. It now posts the same periodic snapshot the audio kit does: bitrate, packet loss, jitter and latency, once every 15 seconds, over the per-user bearer (and not at all without one, rather than into a guaranteed 401).
Latency is read from the transport's selected ICE candidate pair, so a viewer - who publishes nothing, and is most of the audience - reports it too. Unknown latency is sent as null, never a fabricated zero.
A re-established session asks the engine where the room is #
A room lives on a media node the engine assigns, and the engine can move it. A client that reconnected to its remembered node was observed landing on a different node than the one hosting its room - connected, and alone.
VoxeraRoomController.rejoin() mints a fresh token for the room on every
attempt and dials the url that answer carries. It resumes first (above),
re-mints second, and is bounded by maxAttempts (defaulting to
autoRejoinMaxAttempts).
// The kit re-establishes the session by itself; you decide when to ask.
final back = await controller.rejoin();
The kit gets the user back into the room by itself #
Everything above still needed an app to notice the drop and ask. Now the kit
does it: when a session ends without the app asking - a network change, an SFU
restart, a carrier handoff - it resumes the existing session if it can, and
otherwise asks the engine where the room is and re-establishes on the same
schedule (1s, 2s, 4s ... capped at 30s, with jitter). One attempt at a time,
never two, however many drop events or rejoin() calls arrive at once.
After autoRejoinMaxAttempts (10 by default) it stops and calls
onRejoinFailed - one verdict, not one per attempt. That is your cue to show an
error and leave the room; the kit will not try again on its own until the next
connect().
Recovery stops immediately - including an attempt that is mid-backoff - when:
- your app calls
leave(), or disposes the controller; - the user was banned or signed in on another device (coming back would fight a decision made about that user);
- you set
autoRejoinEnabled = false.
controller
..autoRejoinMaxAttempts = 10 // default
..onRejoinFailed = () => showError(); // the kit has stopped trying
// controller.autoRejoinEnabled = false; // your app owns recovery instead
// controller.isRejoining // true while it is trying
// controller.rejoinPolicy // the backoff schedule, if you must tune it
If your backend signs tokens, set controller.tokenProvider - recovery mints
a fresh token on every attempt and cannot work without a way to get one. With no
token source the kit does not dial blindly; it calls onRejoinFailed.
3.0.0 #
What a broadcast costs the user — in their data plan, their battery and their phone's heat — is now the thing this kit is tuned for.
This kit shipped defaults meant for desktop broadband: 720p at 30 fps, published with a software codec. Measured end to end that is 2.31 Mbps of uplink — about 1 GB an hour out of a host's phone, and ~765 MB an hour for a viewer watching full-screen. Compared against the competing SDK our customers' users could be on instead, which publishes 360p / 600 kbps / 15 fps for host, co-host and audience alike, we were sending roughly four times the data and asking for twice the encoder work — for the same product, to an audience whose handsets are weaker than any device we had tested on and whose data is metered.
The codec — the single largest change #
Camera tracks now publish H.264 instead of the engine's VP8 default, with VP8 kept as the backup codec for anything that cannot take H.264.
Almost no Android phone has a hardware VP8 encoder — libwebrtc only enables one for Intel parts — so VP8 meant libvpx encoding on the CPU, and decoding on the CPU for every viewer too. Every Android device made in the last decade has hardware H.264 in both directions. On the phones this product actually runs on, this is the largest battery and thermal item in the pipeline, and it applies to senders and receivers alike.
Simulcast still works: the engine's Android factory wraps the hardware encoder in a simulcast adapter written specifically to handle H.264.
VoxeraVideoQuality.auto — and it is now the default #
The kit picks the tier from the device at connect: low on a phone with four cores or
fewer, sd on everything else. It never resolves above sd — moving up is a decision
the app makes explicitly, not one a core count makes for the user. VoxeraDeviceClass.override
lets a host app that knows the handset better than a core count does say so.
This adds no dependency: the signal is Platform.numberOfProcessors from dart:io.
The ladder was retuned #
| tier | was | now |
|---|---|---|
low |
640×360 · 450 kbps · 20 fps | 640×360 · 500 kbps · 20 fps |
sd |
960×540 · 800 kbps · 25 fps | 960×540 · 900 kbps · 20 fps |
hd |
1280×720 · 1.7 Mbps · 30 fps | 1280×720 · 1.4 Mbps · 24 fps |
fullHd |
1920×1080 · 3 Mbps · 30 fps | 1920×1080 · 2.5 Mbps · 24 fps |
No tier asks for more than 24 fps. Frame rate costs encoder time linearly and buys very little on a talking head, so it is the cheapest thing to give up on a weak phone.
The capture rate is now capped as well, not just the encoding. Without that the camera kept producing 30 fps and every surplus frame was colour-converted, run through the effects processor, and handed to an encoder that discarded it — work paid for twice on the devices that could least afford it.
PK battles no longer double the host's uplink at full quality #
A battle publishes the same camera into two rooms at once, and the cross-room copy carried no publish options at all — it fell back to the engine's own defaults, which at the old HD default meant roughly 4.6 Mbps out of a single phone. It now goes out one tier below the home room, which costs nothing visually because the host occupies half the screen during a battle. Its microphone follows the same speech preset as the home room instead of falling back to a music preset.
Audio publishes as speech #
Unset, the engine falls back to a 48 kbps music preset. A live room carries a talking voice, which opus carries at 24 kbps with no audible loss. DTX is set explicitly rather than inherited, so a default moving underneath us fails a test instead of quietly doubling everyone's data.
What did not change, and is worth knowing #
Simulcast and adaptive stream stay exactly as they were, and they are the reason this kit is structurally cheaper than the alternative regardless of the numbers above: a 104×150 guest tile pulls the 180p layer here, where in a kit without simulcast the same thumbnail decodes the full stream. Every tier still publishes a small layer.
Migration #
Nothing to change. If you were relying on the 720p default, pass
VoxeraLiveRoomConfig(videoQuality: VoxeraVideoQuality.hd) explicitly — and read the table
above, because hd is now 24 fps.
2.4.0 #
Crowded streams cost what a small one costs. Every participant event — an
arrival, a departure, an avatar change — rebuilt the whole room: jsonDecode over
every participant's metadata, plus a new object and a copied attribute map for
each. One viewer arriving paid for all of them. Filling a room to 300 cost
1+2+...+300 = 45,150 operations, quadratic in room size and paid in bursts
exactly while the audience was arriving. The log line itself read
participants.length, so every event paid it twice.
A participant index now absorbs each event into a single entry. Measured:
fill a room of 300 45,150 -> 300 decodes, 45,150 -> 300 builds
one avatar change 300 -> 0 decodes
- The viewer list is served from the index, so a read costs nothing unless something changed, and a burst of arrivals notifies listeners once instead of once per arrival.
- Roles are decoded once, when they change — not for the whole room on every event, and not again on every read.
- 98 hot-path log calls no longer run in release.
debugPrintis not stripped from release builds;voxeraLogtakes its message as a closure, so the string is never built outside debug.
Public API unchanged.
-
Video Effects could never activate on a live stream. The engine resolves the per-platform entitlement from the
osfield on the token request and fails closed without it — and this kit never sent one. The parameter existed ongenerateToken, but nothing filled it andVoxeraLiveRoomdid not pass it, so every live token arrived with no platform and the signedvideoEffectsclaim came backfalsefor every customer, however they had paid. (The audio kit has always reported it, which is why only live was affected.) The controller now resolves the platform itself when the caller does not supply one — no new dependency,dart:ioanswers the only question the entitlement asks. An app that already passesosstill wins.A server-side change (2026-09-05) already restores the Android + iOS package on the kits in the field; this is what makes a single-platform licence resolvable at all.
2.3.0 #
Makes duplicate delivery diagnosable, and closes a stale-listener hole.
-
dataFrameStream— the same messages asdataStream, plus the transport metadata the decoded payload cannot carry: the per-sendidand the sender's identity.dataStreamdelivers the decoded payload alone, so an app had no way to tell ONE message delivered twice from TWO separate sends of identical content — which is the entire difference between a transport fault and a sender sending twice.dataStreamis unchanged; nothing is injected into the payload map an integration already parses. -
A handler from a released Room can no longer reach the app.
EventsListener.dispose()is async and the teardown does not await it: it cancels its twelve subscriptions one after another, so the data handler (eighth) stops only after seven awaits — and a connect retry builds the next Room immediately, with the previous teardown bounded to two seconds and left to finish in the background. Every handler now checks the Room generation it was created in, so the window is closed by construction rather than by microtask timing. -
voxera_media_clientfloor raised to^2.8.5, which carries two correctness fixes this kit depends on: a replaced subscriber data channel no longer keeps delivering (the same duplicate shape this release makes diagnosable), and uplink audio quality is actually measured — loss and round-trip time were read from the wrong stats report and every speaker was reported as a flawless uplink.
2.2.0 #
Guest invitations become an ask, and the stage clears when people leave. No breaking changes.
- Inviting a guest no longer forces them live.
inviteToSpeakused to promote instantly: the viewer's publish permission flipped and the kit turned their camera and mic on, with no dialog and no way to refuse. It now sends a server-authoritative invitation — the target stays audience until they accept. Accept/decline, a built-in dialog (override withonInvitationUI), andonInvitationFailedfor an accept that arrives too late. SetVoxeraLiveRoomConfig.invitationTimeout(orexpiresInper invite) to give the invitation a window; the engine enforces it and notifies both sides when it closes.addToStagestill promotes immediately, for approving a raise-hand where a second confirmation makes no sense. - A guest's tile disappears when they leave.
leave()only disconnected; the stage state was never updated, so every other client kept rendering the departed guest. A guest now steps off the stage before disconnecting, and when the host ends the broadcast every guest is removed first — so the next live starts empty instead of showing the previous session's guests. The engine also cleans up on an unexpected disconnect (crash, network loss).
2.1.1 #
Hardening — no API changes.
- The media audio profile is claimed at controller construction — the
native engine is built once per process by the first thing that touches it,
and a host's camera preview could race the connect-time claim. See the new
"Audio engine setup" README section: apps should also call
VoxeraMediaClient.initialize(mediaAudioProfile: true)first thing inmain(). - pub.dev now lists the supported platforms (Android, iOS) explicitly.
2.1.0 #
Media audio profile — the stream no longer sounds like a phone call. No breaking changes.
- A live stream now runs in the platform's MEDIA audio profile, not the
telephony one. Before this, the OS treated a live stream as a phone call:
Android sat in
MODE_IN_COMMUNICATION(call volume, other apps blocked from the mic, "already in a call"), and on iOS the kit's Bluetooth routing set WebRTC's call profile —playAndRecord+voiceChat— on every join, viewers included: earpiece routing at call volume, and a session that needs microphone permission a viewer never granted, so it could fail to activate and play nothing at all. - Now: a viewer gets a pure
playbacksession (media volume, speaker/BT routing, no "in call" state, never touches the mic); a broadcaster records undervideoChat(speaker-routed) instead of the telephonyvoiceChat; Android runsMODE_NORMAL+ themusicstream with WebRTC's software echo-cancellation/noise-suppression/AGC kept on the mic. Bluetooth routing still works on both platforms — on Android via a re-applied media config with forced device routing, on iOS implicitly from the media session. - The flutter_webrtc speakerphone helper (which arms the iOS call profile) is funnelled through one platform-guarded call site, with tests pinning the session recipe per track state and validating every recipe against what AVAudioSession actually accepts per category.
voxera_media_clientfloor raised to^2.8.1(themediaAudioProfileengine flag).
2.0.2 #
Documentation fix — no code changes.
adminIdsResolverdoc now describes what actually happens: resolved identities feed the HOST client's promotion targets, and the owner promotes them toadminvia the owner-only role endpoint as they appear in the room. The doc previously described a client self-upgrade mechanism (upgradeSelfRole) that never existed in this kit — clients never self-assert admin.
2.0.1 #
Battery/network fixes — no breaking changes.
- Background video pause (viewers). When the app goes to the background,
the kit now disables every remote video publication (the SFU stops
forwarding video; nothing is decoded behind a dark screen) and re-enables
it on resume. Audio keeps playing, publishing (host/guest camera) is
unaffected, and Android OS Picture-in-Picture keeps its video. Opt out via
the new
VoxeraLiveRoomConfig.pauseVideoInBackground(defaulttrue). - Mini overlay speaking ring is event-driven — reacts to
activeSpeakersdirectly instead of a 1s polling timer. activeSpeakersis now single-sourced from the engine's active-speakers event; the state backstop poll no longer duplicates it and runs every 2s (was 300ms), only for mute/camera state.
2.0.0 #
BREAKING — new media engine generation #
- The kit now runs on
voxera_media_client(the Voxera media engine client) instead of the previous third-party RTC client. Public media types are re-exported under the new names. Apps on 1.x keep working unchanged — 1.x stays on the old engine path; upgrade to 2.x deliberately, not viapub upgrade.
Added — official server-signed token support #
VoxeraRoomController.adoptTokenResponse(VoxeraTokenResponse)— adopts a token minted outside the controller (your backend callingPOST /api/v1/tokenwithX-App-Secret): applies the per-user bearer to every in-room API client (stage/ban/role/participant) exactly likegenerateTokendoes. Idempotent.VoxeraLiveRoom.tokenProvider— new optional widget parameter (same shape as the audio-room kit's): supply a token from your backend and the widget adopts + connects with it, falling back to its owngenerateTokenwhen the provider is unset, throws, or resolves null.
1.6.0 #
- Developer-controlled video publish quality. New
VoxeraVideoQualityenum (low360p /sd540p /hd720p /fullHd1080p) exposed asVoxeraLiveRoomConfig.videoQualityandVoxeraRoomController.setVideoQuality(call beforeconnect). The tier drives BOTH the camera capture resolution and the publish encoding (defaultVideoPublishOptions.videoEncoding+ simulcast layers) for every local camera path: host self-preview → Go Live, guest go-live,setCameraEnabled, and reconnect re-publish. Default is HD (720p) — unchanged capture behavior, but the publish bitrate is now pinned to the tier (~1.7 Mbps for HD) instead of the engine's derived default.
1.5.1 #
- Distinguish a not-activated service from a ban on the token endpoint. A non-ban
403(e.g.Type 'live_stream' is not enabled for this project) now throws the newVoxeraServiceNotAvailableExceptioninstead ofVoxeraBannedException. - The built-in connect-error view shows a distinct "not available" message and hides Retry
for that refusal (retrying can't help). Adds
VoxeraRoomStrings.serviceNotAvailable(EN + AR).
1.5.0 #
- Video Effects entitlement (trusted, token-signed). The kit now decodes a
server-signed
videoEffectsentitlement from the join token (the engine resolves it per-platform against the requesting client'sosat mint and stamps a boolean into the token'smetadataclaim) and threads it into the video-processor factory. Effects therefore run only when the customer has activated + paid for the current platform; unentitled sessions get a passthrough processor. The signature is read client-side WITHOUT verifying it (the SFU verifies the token on join); the processor is the authoritative gate.- Breaking: the processor factory now receives the entitlement —
VoxeraLiveRoomConfig.buildVideoProcessorandVoxeraRoomController.setVideoProcessorFactorychanged fromTrackProcessor Function()?toTrackProcessor Function(bool entitled)?. Forward the flag to your processor, e.g.(entitled) => VideoEffectsProcessor.create(entitled: entitled). - New
VoxeraRoomController.videoEffectsEntitledgetter (valid afterconnect) so UI can surface an "activate to unlock" hint. - Backward-compatible / fail-open: a token with no
videoEffectsclaim (older engine) keeps effects working; only an explicitfalsedisables them.
- Breaking: the processor factory now receives the entitlement —
1.4.0 #
-
Seat grid → unbounded stage. A live room is no longer a fixed 4-tile seat grid (host on seat 0 + up to 3 guest tiles).
live_streamis now ALWAYS the engine's unbounded seatless stage: the media room is uncapped (maxParticipants: 0) so viewers are unlimited, and this package decides how many co-host tiles to surface (still 4 by default — the cap is now purely a UI choice, not an engine limit). Every non-owner joins asaudience; the host promotes co-publishers post-join. -
Type-first token.
generateTokennow sendstype: 'live_stream'toPOST /api/v1/tokenand no longer sendsservice/kindorseat_count/seat_mode/host_seat— those are ignored forlive_stream. The engine still accepts the legacyservice(rooms)+kind(live) fields, so an un-migrated app keeps working; this version opts into the canonical type. The sameapp_id/app_keyworks for every product type the project has enabled —typeis a per-request field, not a credential. A request for a type the project hasn't enabled returns403 "Type 'live_stream' is not enabled for this project". -
Publishing decoupled from moderation. Roles are server-authoritative and the engine clamps a non-owner's claimed role to
audience(a client can no longer self-grant publish by claimingrole: 'host'):host— the verified room owner (publishes and moderates).guest— a host-invited co-publisher (publishes only).admin— an owner-promoted moderator that moderates only and is never on camera (decoupled — promoting to admin no longer grants a tile).audience— default (neither).
-
New
stage_api(VoxeraStageApi, exported viastage_api.dart) for thelive_streamstage endpoints (alllive_stream-only; the engine returns400on a seated/non-live_streamroom and403iflive_streamisn't enabled):getStage→GET /api/v1/rooms/:name/stage→{ members: [{ identity, name, role }] }(publishers = host + guests).addToStage→POST /api/v1/rooms/:name/stage/add{ target_identity }(host/admin → grants publish, sets roleguest).removeFromStage→POST /api/v1/rooms/:name/stage/remove{ target_identity }(host/admin → back toaudience).leaveStage→POST /api/v1/rooms/:name/stage/leave(self step-down).requestStage→POST /api/v1/rooms/:name/stage/request(viewer raise-hand; the engine notifies host/admins via a_stage_requestdata message — no server-side queue).
The actor is resolved server-side from the per-user bearer;
identityrides the body as a dual-mode fallback. Stage state arrives over the data channel as_stage_update(roster) and via the_stageroom-metadata key for late joiners;_stage_requestis the raise-hand ping. -
Moderator promotion reuses the existing role endpoint —
PUT /api/v1/rooms/:name/participants/:identity/role{ role: 'admin' }, owner-only. On a stage room this grants moderation but not publish; the engine refuses (409) to add anadminto the stage to keep the two capabilities disjoint (demote first). -
Removed
seat_apiandspeaker_apialong with the invite/request-to-go- live invitation handshake (/seats/*,/speakers/*includingspeakers/invite+invitations/:id/accept|decline). The live room is the stage now; seats/speakers remain invoxera_audio_room_kit(and on the engine foraudio_room+ the legacy live kit) but are gone from this package. Breaking for integrators driving seats/speakers directly: switch toVoxeraStageApi. -
Minimum engine version: requires an engine build with the type-first token path and the
live_streamstage endpoints (enabled_types+/stage/*). Older engines that only understandservice/kindwill reject thetypefield — stay on1.3.0against those until the engine is upgraded.
1.3.0 #
- No-backend credentials (recommended): pass
VoxeraLiveRoom(appKey: ...)/VoxeraRoomController.initApi(appKey: ...)— the project's publishable app key. The kit mints tokens directly from the engine (X-App-KeyonPOST /api/v1/token), and the engine signs the returned per-useruser_tokenwith the projectserver_secretserver-side, so the secret never ships in the app and no integrator backend is required. The kit applies thatuser_tokenas theAuthorization: Bearerfor all in-room/moderation calls (persisted acrossinitApire-inits, so it survives restore-from-minimize). - Removed
tokenProviderand itsVoxeraTokenRequest/VoxeraTokenBundle/VoxeraTokenProvidertypes (added in 1.2.0). The no-backendappKeyflow above replaces it. Breaking for integrators who adoptedtokenProvider: migrate toappKey. - Removed
serverSecretfromVoxeraLiveRoomandVoxeraRoomController.initApi(deprecated in 1.2.0). Shipping the project secret in an app let anyone extract it and mint tokens for any identity/room. Breaking:appKeyis now the only credential and is required onVoxeraLiveRoom. The legacyX-App-Secretheader path is gone (VoxeraApiClientno longer takesappSecret). - A leaked
app_keycannot forge bearers offline or call the server-to-server API, and rotates independently via the engineregenerate-credentialsadmin endpoint.
1.2.0 #
- Secure credential mode: new
tokenProvidercallback mints tokens via the integrator's own backend (which holds the project secret and authenticates the real user) instead of embeddingserverSecretin the app. The kit never sees the secret; the returned per-useruser_tokenbecomes theAuthorization: Bearerfor all in-room/moderation REST calls. - Deprecate
serverSecretonVoxeraLiveRoomandVoxeraRoomController.initApi(now optional). Shipping it in an app lets anyone extract it and mint tokens for any identity/room. Existing callers keep working in legacy/dual mode. - Add the
VoxeraTokenRequest,VoxeraTokenBundle, andVoxeraTokenProvidertypes (exported viatoken_provider.dart);VoxeraTokenResponsegainsuserToken. - The secure-mode per-user bearer is persisted on the controller and re-applied whenever the API clients are rebuilt (e.g. restore-from-minimize re-inits without re-minting a token), so in-room/moderation calls stay authenticated.
generateTokenvalidates thetokenProviderbundle and throwsVoxeraTokenExceptionon an empty token/url instead of failing later in connect.
1.1.0 #
- Single-active-session enforcement: send a stable per-install
device_id(persisted viashared_preferences, auto-resolved ingenerateToken) and handle the_kicked(signed_in_elsewhere) data event through the existing exit funnel with a distinct "signed in on another device" notice and dialog. - Add
VoxeraRateLimitedExceptionfor429responses from the token endpoint. - Split the API into separate token and engine clients, both currently routed
through
stream.zavryn.online; configure via theengineBaseUrlparameter oninitApi. - Security: stop mirroring user attributes into participant metadata (server-owned, spoofing vector); chat text/sender name are treated as untrusted and rendered plain-text only.
1.0.0 #
- Initial standalone release. Extracted from the Tempo-Live monorepo into its own package repository.
- Real-time video live room (host camera + up to 3 guest video tiles) built
on the same seat state machine as
voxera_audio_room_kit. - Camera tiles, invite / request-to-go-live, host force-control of guest media, real-time chat over the data channel, tiered reconnection, and minimize / Android OS Picture-in-Picture.
- Pairs with
voxera_video_effects_kitviaVoxeraLiveRoomConfig.buildVideoProcessor(a video track processor) for real-time filters / beauty effects.