utd_live_room_kit 3.8.11
utd_live_room_kit: ^3.8.11 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 #
3.8.11 #
๐ด Every cross-room re-wrap since 3.8.5 was dead code. This is the release where it actually runs.
-
TrackUnmutedEventis built inLocalTrackPublication._onTrackMuteUpdatedEventand sent to[participant.events, participant.room.events]. It is never emitted on the track's own emitter โ which is precisely what 3.8.5, 3.8.8, 3.8.9 and 3.8.10 subscribed to. So the cross-room microphone and camera were never re-wrapped after an un-mute, in any of those four releases, each of whose changelogs said they were.The integrator measured three consecutive builds and reported that the failure signature had not moved by one unit. They were reading it correctly, and asked the right question โ whether the thing they were describing was the thing being fixed. It was; the fix simply could not run.
Both watches now ride the HOME ROOM's listener, filtered to the local participant so a remote un-mute never re-publishes anything of ours. The first-camera watch already lived there, so this is one subscription where there were three.
-
A test that fails on the old shape. Four releases passed their suites while shipping this: the tests asserted the arithmetic and the widgets around the feature, and nothing asserted the callback could be reached at all. Two assertions now pin the contract on both sides โ that the SDK emits on participant+room, and that this controller subscribes there and not on a track. Verified by reintroducing the broken shape and watching it fail.
3.8.10 #
-
A camera switched on for the FIRST time during a battle now reaches the opponent. 3.8.9 closed the toggle case by watching the existing camera track โ which a host who had never switched their camera on in the session does not have, so there was nothing to attach to and the opponent kept a blank tile for the whole battle while the host's own preview worked. The home room is now watched for a local camera publish, which is what
setCameraEnabled(true)produces in that case; the cross-room copy is published then, and the ordinary un-mute watch is attached to the new track from that point on. -
The re-publish path refuses a muted camera, exactly as the start path does. A mute landing between the publish event and the re-wrap would otherwise re-create the dead-handle publication this whole mechanism exists to prevent.
3.8.9 #
A host whose camera was off when a battle opened published nothing into the opponent's room โ not even their voice.
-
The cross-room publish no longer requires a live camera. It used to return early when there was no camera track at all, and โ worse โ it wrapped the home camera's native handle without checking whether that camera was actually capturing.
_homeCamerastill returns the publication while the host has their camera switched OFF: muted, native track stopped. Publishing that gave the opponent's room a publication fed by a dead handle, which ended one of two ways, both seen in production on a live battle:- the publish threw and the whole cross-room session was rolled back โ the
server recorded the host joining the opponent's room and leaving with
CLIENT_INITIATEDin the same second, so the opponent saw and heard nothing at all; or - it succeeded and froze on the last frame before the camera went off.
Now the connection is made and the microphone is published regardless, the camera is published only when it is genuinely capturing, and
_watchHomeCameraadds it the moment it comes back. The voice never waits on the camera.Known remaining gap: if the host has never published a camera at all in the session, there is no track to watch, so a camera enabled for the FIRST time mid-battle is still not picked up. Publishing the first camera track mid-battle is the rarer case; it is tracked separately rather than papered over here.
- the publish threw and the whole cross-room session was rolled back โ the
server recorded the host joining the opponent's room and leaving with
3.8.8 #
-
๐ด
kitVersionwas stuck at3.8.3. The kit names itself on every request (X-UTD-Kit: <package>/<version>), and that header is how we tell which projects have moved off the publishable-key mint โ a decision that stops a paying customer's app if it is made on a wrong answer. The constant was not bumped with 3.8.4, 3.8.5, 3.8.6 or 3.8.7, so all four reported themselves as 3.8.3 on the wire. A test already guarded this; it was not read before publishing. Corrected, and it now matches. -
Two score-bar tests asserted the widget's SOURCE TEXT rather than its behaviour, so they failed on a refactor that preserved both properties they cared about. Replaced by the behavioural tests in
pk_score_bar_test.dart.
3.8.7 #
The opponent's tile froze the moment a host toggled their camera โ and the server could not see it happen.
-
The cross-room CAMERA is re-wrapped on toggle, as the microphone already was. 3.8.4 fixed the mic half of this and left the camera: the cross-room video wrapper holds the home camera's native handle as it was when the battle started, turning the camera off stops that handle, and turning it back on runs
restartTrack()โ a NEW native track, withreplaceTrackcalled on the HOME sender only. The cross-room sender kept pushing the stopped one, so the opponent's room received the last frame before the toggle, frozen, while the host's own preview stayed perfectly live.Invisible from the server by construction: the cross publication is never unpublished, so its span stays open and its track id never changes. Production showed one continuous cross-room video span across three battles while the OS camera log showed four disconnect/reconnect cycles inside them. Measured on two devices by the Meow Live team (2026-09-23): each host's own preview live, each host's remote tile frozen, in both directions, with the frozen frame showing a scene from minutes earlier.
-
Requires
utd_media_client^2.9.8.
3.8.6 #
-
One gift no longer looks like a lost battle. The PK score bar placed its divider at
scoreA / total, so the first point scored was 100% โ and the 0.08/0.92 clamp then rendered that as a bar slammed against the edge. An integrator photographed it and read the divider as broken, which is fair: visually it is indistinguishable from a battle already over. The share is now pulled toward the centre while the totals are small and earns its position as they grow (three phantom points a side, whose influence decays as real points arrive). 0-0 is dead centre, 1-0 leans, 50-0 is as decisive as it should be. The clamp stays, so the losing colour is always visible.Pinned by tests: centre at a tie, symmetry, monotonic commitment at a fixed ratio as totals grow, and never leaving the bar for any input including negatives.
3.8.5 #
-
utdIntegrationErrornow actually reaches the device log. It used onlydeveloper.log, whose output goes to the Dart VM service โ so in a release build with no debugger attached nothing was listening, despite a comment claiming it survived release. An integrator searched 276,000 lines of logcat from a live release device and found not one line from it, whileprint-based lines from the same session were all present. It now writes throughdebugPrint(which is not stripped from release) first, and still emits the structureddeveloper.logrecord for an attached DevTools session. -
Requires
utd_media_client^2.9.7, which fixes a failed un-mute leaving the microphone or camera dead for the rest of the session.
3.8.4 #
A PK battle's cross-room link was torn down by a message about a DIFFERENT battle โ and the log line that would have shown it was compiled out of release.
-
Cross-room teardown now checks WHICH battle ended.
_teardownIfTerminalcompared nothing: any terminal_pk_*message tore down whatever cross-room publish was live, so a_pk_cancelledfor a battle that had finished 30 seconds earlier killed the connection of the battle running now. The message is broadcast to both rooms, so both hosts lost the link in the same instant โ from the server it looked like a successful join followed one second later byCLIENT_REQUEST_LEAVEon both sides. The live publish is now stamped with its battle id and ignores terminal messages for any other battle. A payload with nobattleno longer tears anything down either. -
Stale battle state can no longer overwrite the live one.
battle.value = bwas unconditional, so a cancelled battle replaced the running one in the UI state. A terminal message for another battle is now dropped; a new (non-terminal) battle still takes over. -
The two failure paths are visible in release builds.
utdLogiskDebugMode-only by design, which meantdual-publish failedcould never appear in a build a user actually runs โ leaving "the app left" and "publishing threw and rolled back" indistinguishable from a device log. Both now useutdIntegrationError(SEVERE, survives release). That includes a case nobody could see before: with the camera closed, the cross-room publish returned before publishing the microphone, so the opponent's room received neither video nor audio. -
Un-muting restores the opponent's audio. The cross-room mic wraps the home mic's native handle (a phone opens the microphone once; the handle cannot be cloned). Muting the home mic stops that shared handle โ the
isDetachedguard protects the cross WRAPPER from stopping it, not the handle from its owner โ and un-muting callsrestartTrack(), which replaces the track on the home sender only. The opponent's room kept publishing a dead handle until the next battle re-read the mic. The kit now re-wraps and re-publishes the cross-room mic onTrackUnmutedEvent, and skips the work when the handle did not change.
3.8.3 #
-
Reports now carry the audio ROUTE the sample was captured on โ
bluetooth,wired-headset,speaker,earpiece, orother.The first hour of
mic_rmsdata raised a question it could not answer on its own: the same handset reported a 0.020 microphone floor in one window and 0.181 in another, minutes apart, in one session. The only thing separating the two in the row was the jitter figure โ which happens to fingerprint the capture path. That is an accident of the data, not a measurement, and no query should lean on it.The route turned out to be a Bluetooth headset for BOTH regimes, which killed the obvious reading: the same headset gave the cleanest floor of the session AND the worst. Something else decides which, and without the route on the row that question cannot be asked at all.
๐ The device LABEL is deliberately not sent โ headset names carry people's names. The category answers the question; the name does not.
-
Note on reading
mic_rms: it is the mean over the whole window, so a window that contains speech reads high no matter how quiet the room is. A noise floor means averaging it over windows wherebitrateis low โ i.e. where the encoder had nothing but silence to send. Reading it ungated says "this person talks", not "this microphone is noisy".
3.8.2 #
-
A microphone-floor number that survives on the platform branch.
When a handset is on the platform's voice processing, WebRTC's canceller stands down and
erl/erlearrive null on every sample. Measured across two hours of live traffic: the software branch reported figures on 186 of 242 samples, the platform branch on 0 of 161. The branch where we hand the work to the device was the branch with no instrument on it โ a phone whose built-in processing does nothing looked exactly like one where it works, and the first sign either way was a user complaining. Which is how it surfaced.Reports now carry
mic_rms: the mean microphone amplitude over the whole window, silences included, from the cumulative energy counters. Read it as a noise floor โ a suppressor that works lets the microphone go quiet between words; one that is not running carries the room into every sample. It works on both branches, so the two are finally comparable. -
Requires
utd_media_client ^2.9.6โ the release that stops a failed subscriber connection from resuming in a ~1 s loop forever, and gives the video renderer a single writer for its track. The floor is raised rather than left at^2.9.4because a trackedpubspec.lockotherwise holds an app on the old resolution while itspubspec.yamlsays it is current.
3.8.1 #
-
A host who muted and unmuted their microphone was never heard again. "Speaker off" had two independent implementations: one disabled tracks and kept its own flag, the other disabled publications and kept another. Neither read the other, so a room muted through one path and unmuted through the other left a speaker silenced with every flag reading "unmuted" โ permanently, because nothing touched that publication again. There is now one owner, and it works at the publication level: a publication that has not been subscribed yet has no track to disable, which is how a newcomer's audio escaped the mute entirely.
-
With the speaker off, anyone who started talking afterwards was heard at full volume. Subscribing was handled and the toggle was handled; the event in between โ a participant already in the room who unmutes โ was handled nowhere. It is now, in both directions: it applies the mute, and it heals a publication left disabled by an earlier one.
-
Remote camera and microphone state updates on the event, instead of on a 2-second poll. A camera going on or off used to take up to two seconds to appear, which on two devices side by side reads as not appearing at all. The poll stays as the backstop for an event that never arrives.
3.8.0 #
From one integrator test session on two handsets in two places. They filtered the list against their own code first โ this is what was ours.
- The result badge never went away. Clearing the flash wrote a sentinel into the same field that decides whether to START it, so the next rebuild read "not shown yet" and armed the timer again โ and any rebuild did it. A "Draw" badge stayed on both devices after the battle was over and the panes were gone.
- A viewer saw the broadcaster mirrored.
VideoViewMirrorMode.autoasks the renderer to guess whether a track is a local front camera, and that guess stops being right behind a video effects processor or a restarted track. Both ends of one broadcast measured facing the same way (+0.64 as-is against +0.27 flipped) when a selfie preview and a viewer must always be mirror images. The local tile is now mirrored explicitly, and only while the front camera is running; remote tiles never are. - A minimised room could become unreachable.
restore()left the minimised state before validating the route, so a missing route name gave: floating window gone, no room opened,isMinimizingfalse so it never returned โ with the connection still live. The check now runs first. The same fix ships inutd_audio_room_kit, which carries this file byte-identically. - Battle name chips are bounded and readable over a bright camera frame.
New in UTDPkConfig:
scoreBarPositionโtop(default) orbottom. Both are in use in this market; it is a product decision and it belongs to you.onHostTap(context, identity)โ fires when a viewer taps a host's pane during a battle. The kit opens nothing: it does not know what a profile looks like in your app. Leave it null and the panes stay inert, exactly as before.
3.7.2 #
-
Requires
utd_media_client2.9.4, which asks the audio engine for hardware echo cancellation and noise suppression off while keeping Android's call capture path โ the combination that lets the software canceller converge on handsets where it previously did nothing. Measured across one live room on three phones: 8-12 dB cancelled on a Samsung, nothing at all on a Redmi Note 9, and both failing phones transmitting room noise continuously because the suppressor was not running either.This needs one line in your app (the option lives in our fork of the webrtc plugin; without it the build simply falls back to the platform canceller):
dependency_overrides: flutter_webrtc: git: url: https://github.com/UTD-Stream-Engine/webrtc-utd.git
3.7.1 #
-
Echo cancellation moves back to the platform's own canceller, via
utd_media_client2.9.3. A publisher already runs in Android's communication audio mode, which is where the built-in canceller lives, so the module now keeps its hardware AEC and hardware noise suppressor instead of handing the job to software.Why, in one line: measured across one live room on three phones on the same build, the software canceller cancelled 8-12 dB on a Samsung, nothing at all on a Redmi, and reported nothing on a third. Software cancellation depends on the device's audio delay, which is not ours to fix. The platform path is the one every phone vendor tests for its own call app. A device that reports no built-in canceller still gets the software one automatically, so every handset ends up with exactly one.
3.7.0 #
The battle screen now looks like a battle.
- One score bar across the top, each side in its own colour with its total at its end, and a lit seam where they meet. It ANIMATES to a new split rather than jumping โ a gift moving the line is the drama of a battle, and a hard cut reads as a glitch because the eye registers a position, not a change. 0-0 is an even split, and a wipeout still leaves the losing colour visible.
- The clock sits over the seam, in tabular figures so it does not twitch every second.
- The top three gift senders on each side, under their host, ranked inward
so the two leaders face each other across the centre. Rendered from the
engine's
top_a/top_bโ nothing to build on your side. A scoreboard says a side is winning; these say who is winning it for them. - Every piece is optional:
showTimer,showTopGifters,topGiftersCount,scoreColorA/scoreColorB. Turn any of it off and draw your own.
To make the senders appear, include who sent the gift when you award points:
POST /pk/{battle_id}/score now takes an optional user_id, user_name and
user_avatar. Awarding without them behaves exactly as before.
3.6.4 #
Reported from a live battle. All six from one session:
-
The PK opponent is no longer one of your viewers. A battle makes each host publish into the other's room, so the challenger arrived as an ordinary participant: in the member list, in the head count, and โ because they publish with a stage role โ among your own guests. Your audience is who came to watch you; the other host is the other side of a challenge. Filtered from the battle itself, never from anything the remote device claims about itself.
-
The black backdrop now covers the whole screen, so the room video cannot show above the panes either. And
UTDPkBackdropis a separate layer you mount yourself if you compose your own room screen โ if the overlay is running without it, the kit now says so in the log (release included) instead of letting the host appear twice. -
The start countdown was drawing across the entire battle area. A Container given an alignment expands to fill whatever bounded space it is handed. It is a 72px badge.
-
UTDPkBattleSheetโ both hosts, the score, the time left and an End button, for the two hosts only. Open it from your own PK button:UTDPkBattleSheet.show(context, controller: controller). The End control cannot live on a controls bar the kit does not own, and apps that supply their own bar had no way out of a battle. -
Voice is published at 48 kbps, not 24. Measured on a live broadcast: 20-23 kbps out with zero packet loss and a host reporting the audio was not clear. A clean network and a thin voice is an encoder ceiling. Audio at 48 kbps is under 3% of a stream already carrying 1-2 Mbps of video. The cross-room copy matches, so the opponent's audience hears the same quality.
3.6.3 #
- Every quality report now says which handset produced it โ
device_model,os_name,os_version, read from the device itself and cached once per process. The echo telemetry added earlier today caught two handsets in one live battle, on one build, with opposite results: one cancelling 6-9 dB, the other cancelling nothing for its whole session. Nothing in the report said what either device was, so the obvious next question โ which handsets fail โ had no answer. Your app can still pass these togenerateToken; this is what answers when it does not.
3.6.2 #
-
Two more causes of "echo" in a PK battle, both measured on a live Egypt โ Bangladesh battle.
Each host was receiving the other one twice. A battle makes every host join the opponent's room to publish into it โ and the kit was also SUBSCRIBING there, to a microphone and camera it already receives at home, where both panes render from. Two copies of one voice over two connections with two jitter buffers: the second arrives tens of milliseconds late and sounds exactly like echo. No echo canceller can remove it, because it is not echo. The cross-room session is now publish-only, which also stops pulling the opponent's video stream down a second time.
The publisher's audio profile. Requires
utd_media_client2.9.2: a device with a microphone open now runs on Android's voice path, where the canceller can align to the delay, instead of the music path, where on some handsets it cannot and cancels nothing. -
PK, from an integrator's report on the 3.6.1 build:
- the battle backdrop was painting OVER the chat, the gift rail and the controls bar โ the widgets were mounted and taking taps behind an opaque rectangle, so for the length of a battle nobody could send a gift. It is now a separate layer, below the chrome.
- host names and pictures arrive with the battle. A missing name is now empty instead of the host's identity: invites read "48 invited you to a PK battle" and camera-off tiles drew a circle with "4" in it.
- the End-battle control is an icon on the controls bar, shown only to the two hosts in the battle, instead of a text button over the chat.
- a 3 ยท 2 ยท 1 ยท GO countdown at the seam, anchored on the engine's start time so both rooms count together. The VS badge clears with it.
3.6.1 #
-
Echo, on every Android 10+ device with a microphone open. The kit runs the media audio profile so a room is not treated as a phone call โ and on Android 10+ that left NO echo canceller running at all: the native audio module announced a built-in AEC (so libwebrtc's AEC3 stood down) while the built-in one never engaged under the profile's
MODE_NORMAL. Measured on a live PK on 2026-09-20: 112 quality samples, microphone live,echoReturnLossreported on zero of them. Fixed inutd_media_client2.9.1, which this release requires.Rebuild your app against this version. No code change on your side, and nothing to configure. If your app reports quality telemetry,
audio_erl_dbstarting to arrive is the proof the canceller is running.
3.6.0 #
The host appeared twice during a PK battle.
Reported from a real two-device battle: the two panes render at the top, and the host's own camera also filled the screen beneath them as the chat's background.
One defect, not three complaints. The battle overlay sits ABOVE the room's video stage, and its lower band was deliberately transparent so the chat and controls underneath stayed interactive โ which also let the full-screen stage show through. The duplicate video, the panes looking short and the wish for a black backdrop were all the same missing band.
UTDPkConfig.battleBackdropโ what fills the screen below the panes. Opaque black by default, which removes the duplicate.Color(0x00000000)restores the old pass-through for an app that draws its own background there. The chat and controls beneath stay fully interactive: the backdrop paints but never takes a touch.UTDPkConfig.paneHeightFractionโ share of the screen height the panes take. Defaults to the layout that shipped (5/12); clamped to [0.2, 0.9].
3.5.2 #
A ban was something the user could decline by not tapping.
The banned dialog was awaited BEFORE the room was left, so a banned viewer stayed connected โ and on stage, still able to publish โ for as long as the dialog sat there. Tap nothing and leaving never happened at all.
The same applied to the host ending the live: viewers sat in an ended stream until they acknowledged it.
Both now leave the room, pop the route and run the host's onClose teardown
FIRST; the notice reports what already happened, on a context that outlives the
pop. The minimized case had the same order and is fixed with them.
3.5.1 #
One more echo field: level.
The first real handset to report from 3.10.0 was a host alone in a room for nine minutes โ every report from its own microphone, publishing โ with the media audio profile applied and both echo figures null on all 28 reports.
That reads two ways: either the software echo canceller is not running (which
would be the echo itself), or the media-source stats report never arrives and
the echo figures were measuring nothing. From the outside they look identical.
audioLevel rides that same report. A level with no ERLE means the report
arrives and the canceller is silent; neither means the report never came. One
field, and the ambiguity is gone.
It is worth having anyway: a microphone capturing nothing reads zero here, which is the first thing to check when someone says nobody can hear them.
Also exposed on UTDQualityReporter.reportSample as audioLevel.
3.5.0 #
A quality report was resetting the token renewal it depended on, four times a minute, for as long as a user stayed in a dead session.
Measured on production over three days: 17,553 expired-token refusals, 17,506
of them on POST /api/v1/quality โ this package's own 15-second telemetry
timer โ across 41 identities. One identity was refused 2,879 times over 44
hours without a single re-mint ever reaching the engine.
UTDTokenRefresher's backoff spreads five attempts over ~2.5 minutes, but every
privileged refusal calls it directly. A caller knocking every 15 seconds
restarted the chain from zero before it could space anything out: the backoff
existed and never ran.
- Telemetry no longer asks for a token.
UTDApiClient.posttakesbackground: true, and an expired token is not renewed for such a request. A call the user is waiting on still renews immediately, every time. UTDQualityReporterstands down when it keeps failing โ the gap doubles per consecutive failure up to five minutes, and one success restores the 15-second cadence. New:consecutiveFailures,isBackingOff,nextAttemptIn.- A failed renewal is now visible through
utdIntegrationError(SEVERE, survives a release build). That failure happens in the host app's mint path and never reaches UTD's servers.
The echo canceller now reports on itself. Each quality report carries
profile_is_media (did the media audio profile actually reach the native
engine), plus erl_db and erle_db from RTCAudioSourceStats while
publishing โ ERLE being how many dB of echo the canceller actually removed.
No API removed. No behaviour change for a call a user is waiting on.
3.4.0 #
Host-only PK actions were failing 3,488 times in silence.
POST /pk, POST /pk/match and DELETE /pk/match are gated on the engine
against the room's verified host: anyone else gets a 403. Firing them from a
viewer's device is an app bug, and it was an invisible one โ the rejection was
caught and written to utdLog, which is compiled out of release builds. The
user saw nothing, the app saw nothing, and the only place it existed was the
engine's request log, where production had accumulated 3,488 rejections
against 655 successes โ 3,261 from a single project, still arriving daily.
UTDPkController now refuses those three locally, before the request is built,
reading isLocalHost from the room controller rather than a copy that goes
stale when the host changes. The refusal goes through a new
utdIntegrationError โ logged at SEVERE, so a release build keeps it โ and the
message names the property to gate on rather than merely announcing a refusal.
A failed cancelRandomMatch is reported the same way, for a different reason:
it leaves the room QUEUED, so the next opponent pairs with a host who already
walked away. That is a user-visible outcome, not diagnostics.
About PK scoring #
While tracing this we found that POST /pk/{battle_id}/score has never been
called โ 24 battles since 2026-08-24, every one 0โ0, every one a draw.
That is not a defect in this kit. Scoring is deliberately server-side only: a
device that can post points can win any battle it likes, so there is no score
method here and there never will be. The points have to come from your
backend, with X-App-Secret, when something in your app is worth them.
It was, however, documented nowhere a developer would read it. The engine's developer integration guide now carries a full PK section, and its first block is the warning: without that call every battle ends 0โ0 forever, with nothing in any log to say why.
3.3.0 #
Broadcasters on the loudspeaker echoed โ asking for the media audio profile was also switching every echo canceller off.
UTDAudioMode.enableMediaMode() calls UtdmClient.initialize(mediaAudioProfile: true)
for one reason: keep the OS out of the telephony profile so viewers are never
"in a call" โ other apps keep the microphone (a WhatsApp voice note works with
the room open) and audio routes like music rather than to the earpiece. In the
SDK that flag also disabled the native hardware echo canceller and noise
suppressor, swapped the capture source to a raw microphone, and forced WebRTC's
own software AEC/NS/AGC off โ so a broadcaster was publishing a completely unprocessed
microphone into the room.
Requires utd_media_client 2.9.0, where routing and processing are separated.
Nothing in this kit's own routing or session recipes changes: viewers keep the
media profile exactly as before.
- A dropped audio profile is no longer invisible. The media engine is built
once per process: if anything touches WebRTC before this kit, the profile is
silently discarded and the whole session behaves as a phone call, with nothing
able to repair it.
UTDAudioMode.engineAudioProfileIsMedianow exposes whether it was actually applied,UTDAudioMode.onAudioProfileResolvedreports it to the host app's analytics, and a failure is logged at SEVERE in release builds instead of only in a debug console.
3.2.1 #
Every request now says which kit sent it.
The kits identified themselves with nothing โ Accept, Content-Type, X-App-Id,
X-App-Key and no more โ so the engine could not tell a device on the current
release from one on a build from June.
That gap blocks a specific decision. The publishable app_key mint is the
impersonation path we are retiring, and it can only be closed per project,
after that project's app has actually moved to a server-signed token. Closing it
on an app that still mints with the key stops that app instantly. Without a
version on the wire, deciding which projects have moved is a guess โ and a wrong
guess costs a paying customer their app.
Every request from both HTTP clients (the token host and the engine host) now carries:
X-UTD-Kit: utd_live_room_kit/3.2.1
It is a label, never a credential: it names the library, and nothing about
the app, the user, or the project โ those already have their own headers. That
is what makes it safe to keep in a server log, which matters here: the header
that used to answer this question, X-App-Key, had to be dropped from our access
logs because it was leaking live credentials.
Sent unconditionally on both clients on purpose. A device that only ever mints and a device that only ever acts in-room must both be countable; a header present on some calls and not others would undercount exactly the apps we most need to see. And a request arriving without it is itself the finding โ that is an old kit.
Nothing else changed. Both authentication paths keep working exactly as
before: appKey is still accepted and still required by the widget, and
tokenProvider is still the optional server-signed alternative. No app needs to
change anything to upgrade.
kitVersion is pinned to pubspec.yaml by a test that reads the pubspec and
fails when the two disagree, so the version on the wire can never quietly drift
from the version that was published.
3.2.0 #
The kit now understands "no", and it stops knocking.
Two gaps, one root: the kit read the engine's refusals as prose instead of as a contract, and it had no way to say "this answer is final".
A suspended project is no longer knocked on forever #
The engine answers a suspended project with
{ "message": "...", "code": "project_suspended", "retryable": false }
and this kit read neither field. A 403 was classified by searching its
message for the word "ban" โ "Project is suspended" contains none, so the
refusal became a generic "not available", the redial schedule kept its
30-second ceiling, and the client went on asking: 120 requests an hour per
device, forever. One suspended project produced 5,489 token requests.
Refusals are now classified on code and retryable:
project_suspended/client_suspendedโUTDProjectSuspendedException(a subtype ofUTDServiceNotAvailableException, so code that already handles "not available" keeps working untouched).isClientSuspensiontells the two apart.user_bannedโUTDBannedException, even when the message never says "ban".- Any
retryable: false, on any status and with a code this kit has never seen, is final too. 429and5xxare unchanged: they back off and retry, because backing off IS the answer to a rate limit.
code is read before the status code, so an engine that moves suspensions
off 403 needs no new kit.
An engine that sends neither field behaves exactly as it did. The old text
match still runs, but only when there is no code at all โ so an app on a
not-yet-updated engine sees the same exceptions as before, and a not-yet-updated
app on the new engine still catches everything through
UTDServiceNotAvailableException.
Final means stopped, not slowed #
UTDRedialPacer could only ever slow down. A ceiling is the right answer to a
network that is down and the wrong one to an engine that has already given its
final answer, so the pacer can now be halted: halt() / isHalted /
clearHalt(), cleared automatically by a dial that connects.
The stop lives in the room manager, at the one place a session is opened โ so an
app that calls connect() in its own loop is stopped by the same line that
stops the kit's own recovery. UTDRoomManager.dialingStoppedBy exposes the
refusal, resumeDialing() clears it (for the app that knows the bill was paid).
Recovery stops on the first final refusal instead of spending its remaining
attempts on an answer already in hand.
The user is told the truth, and the developer gets the details #
UTDRoomController.onDialingStopped fires once with the refusal, so the app
can leave the room instead of holding a spinner over a room that is never
coming.
The engine's suspension message is written for the developer โ it explains
that settling the invoice restores service immediately. It is never shown to an
end user: the built-in connect-error view shows the new
UTDRoomStrings.serviceSuspended ("This room is temporarily unavailable. Please
try again later.", localised) with no Retry button. Showing an account's billing
state as if it were a personal block is exactly what the old contains('ban')
match did.
The user token renews itself before the engine starts refusing #
The renewal machinery shipped in the previous release but nothing armed it on
the kit's own minting path: only an app that adopted its own token ever
scheduled anything. An app_key room therefore ran until the engine began
refusing โ 715 refusals for one user in a day, with zero renewal attempts
among them.
generateToken()now arms the ahead-of-time renewal for the token it just minted, in every mode.- A widget-level
tokenProvideris forwarded to the controller, so a server-signed app can be re-minted for by its own backend instead of falling through to "no mint source". - A failed renewal backs off (5s, 10s, 20s, 40s, 60s) and stops at five, rather than either giving up on the first failure or hammering. A refusal the engine called final is not retried at all.
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 (UTDRedialPacer 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:
UTDReconnectPolicy(the schedule) andUTDRedialPacer(the enforcement). - Removed:
UTDConstants.retryDelay. The delay is no longer a single number;UTDConstants.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. UTDTokenResponse.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.
UTDRoomController.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.
UTDVideoQuality.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. UTDDeviceClass.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
UTDLiveRoomConfig(videoQuality: UTDVideoQuality.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;utdLogtakes 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 andUTDLiveRoomdid 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. -
utd_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. SetUTDLiveRoomConfig.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
UtdmClient.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.
utd_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
UTDLiveRoomConfig.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
utd_media_client(the UTD 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 #
UTDRoomController.adoptTokenResponse(UTDTokenResponse)โ 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.UTDLiveRoom.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
UTDVideoQualityenum (low360p /sd540p /hd720p /fullHd1080p) exposed asUTDLiveRoomConfig.videoQualityandUTDRoomController.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 newUTDServiceNotAvailableExceptioninstead ofUTDBannedException. - The built-in connect-error view shows a distinct "not available" message and hides Retry
for that refusal (retrying can't help). Adds
UTDRoomStrings.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 โ
UTDLiveRoomConfig.buildVideoProcessorandUTDRoomController.setVideoProcessorFactorychanged fromTrackProcessor Function()?toTrackProcessor Function(bool entitled)?. Forward the flag to your processor, e.g.(entitled) => VideoEffectsProcessor.create(entitled: entitled). - New
UTDRoomController.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(UTDStageApi, 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 inutd_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 toUTDStageApi. -
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
UTDLiveRoom(appKey: ...)/UTDRoomController.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 itsUTDTokenRequest/UTDTokenBundle/UTDTokenProvidertypes (added in 1.2.0). The no-backendappKeyflow above replaces it. Breaking for integrators who adoptedtokenProvider: migrate toappKey. - Removed
serverSecretfromUTDLiveRoomandUTDRoomController.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 onUTDLiveRoom. The legacyX-App-Secretheader path is gone (UTDApiClientno 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
serverSecretonUTDLiveRoomandUTDRoomController.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
UTDTokenRequest,UTDTokenBundle, andUTDTokenProvidertypes (exported viatoken_provider.dart);UTDTokenResponsegainsuserToken. - 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 throwsUTDTokenExceptionon 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
UTDRateLimitedExceptionfor429responses from the token endpoint. - Split the API into separate token (
udt-stream.com) and engine (engine.udt-stream.com, grey-cloud) clients; configure via the newengineBaseUrlparameter 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
utd_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
utd_video_effects_kitviaUTDLiveRoomConfig.buildVideoProcessor(a video track processor) for real-time filters / beauty effects.