utd_audio_room_kit 3.10.14
utd_audio_room_kit: ^3.10.14 copied to clipboard
Real-time live audio room for Flutter: seat management, real-time chat, mic/speaker controls, speak requests, moderation, and minimize/PiP.
Changelog #
3.10.14 #
-
๐ด
kitVersionwas stuck at3.10.12, so 3.10.13 reported itself as 3.10.12 in theX-UTD-Kitheader โ the header we use to tell which projects have moved off the publishable-key mint. Corrected. (Same slip asutd_live_room_kit3.8.8, found by that kit's own guard test.) -
Requires
utd_media_client^2.9.8.
3.10.13 #
-
utdIntegrationErrornow actually reaches the device log โ same fix asutd_live_room_kit3.8.5:developer.logalone reaches only the Dart VM service, so release builds logged nothing at all. -
Requires
utd_media_client^2.9.7, which fixes a failed un-mute leaving the microphone dead for the rest of the session.
3.10.12 #
-
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.10.11 #
-
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.10.10 #
-
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.10.9 #
- 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 the window never returned โ with the connection still live underneath, audio playing, and the user unable to see the room, reach it, or leave it. The check now runs first. (Found inutd_live_room_kit, which ships this file byte-identically.)
3.10.8 #
-
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.10.7 #
-
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.10.6 #
- Voice is published at 48 kbps again, not 24. We had halved it to save a listener's data โ an eight-seat room costing ~100 MB an hour instead of ~200. Measured on a live broadcast on 2026-09-20: 20-23 kbps out, zero packet loss, and the person listening reporting the voice was not clear. A clean network and a thin voice is an encoder ceiling; nothing downstream recovers what was never encoded. A voice room is not the place to economise on the voice. DTX still stops transmission during silence, so a mostly-listening room does not pay the full rate.
3.10.5 #
- 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.10.4 #
-
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.10.3 #
-
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.10.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 user stayed connected โ hearing the room, and on a seat still able to speak โ for as long as the dialog sat there. Tap nothing and leaving never happened at all.
A ban is not a confirmation. The room is left, the route popped and the host's
onClose teardown run FIRST; the notice then reports what already happened,
over whatever screen the user landed on. The notice is shown on a context that
outlives the pop โ the app's navigator key when one is wired, otherwise the
room navigator captured before teardown.
Covers the minimized case too, which had the same order.
3.10.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.10.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. The report that could not matter was starving the
renewal that could.
- 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, and anything that does not declare itself counts as a user call. UTDQualityReporterstands down when it keeps failing โ the gap doubles per consecutive failure up to five minutes, and one success restores the 15-second cadence. A dead session now costs ~12 refused requests an hour instead of 240, on the user's battery and data. New:consecutiveFailures,isBackingOff,nextAttemptIn.- A failed renewal is now visible. It happens in the host app's mint path
(
tokenProvider, or your backend) and never reaches UTD's servers, so it appeared in no log anyone owned. Reported once per chain throughutdIntegrationErrorโ SEVERE, and it survives a release build.
setCommentsLocked() had no protection against a UI loop. On 2026-09-18 one
device sent 10,568 lock/unlock calls in nine minutes โ 19 a second,
alternating โ every one refused 429, every one swallowed by a debug-only log.
Nobody could see it. There is now a floor of one call per second, and after ten
dropped calls the loop is reported through utdIntegrationError.
The floor is on the rate, never a no-op on the value: commentsLocked
mirrors the server, so if a _chat_lock broadcast is missed it is stale here,
and a host pressing "unlock" on a room that really is locked must still be able
to unlock it. A dropped call returns false.
The echo canceller now reports on itself. Echo has been reported for a week
and every diagnosis so far was read off the source, never measured on a
handset. Each quality report now 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. Joined against the device facts the engine
already stores, that answers "is echo cancellation running, and on whose
handsets" as a measurement rather than an inference.
No API removed. No behaviour change for a call a user is waiting on.
3.9.0 #
Speakers 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 listeners 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 an on-seat speaker 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: listeners 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.8.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_audio_room_kit/3.8.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.8.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.
Kit parity #
UTDRoomController.roomManager is now exposed here too. The live kit has always
exposed it; the two kits differing on it was drift, not design โ and the new
dialingStoppedBy / resumeDialing() are read through it.
3.7.0 #
Staying in the room 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.
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.
Re-establishing now follows a schedule: 1s, 2s, 4s โฆ capped at 30s, with
ยฑ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 user switching rooms 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 seat, role and moderation call in that window
failing silently while the room 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, so a
long visit never reaches the point where the engine starts refusing.
- 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 an audio cut 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.
Every quality report carries latency_ms #
Latency came from sender stats, which only exist while publishing โ so listeners, most of any room, reported none: 3,711 reports a day on one project with the field empty, and latency is the first number anyone asks for when a room feels slow.
RTT is now read from the transport's selected ICE candidate pair, which exists whether or not the user publishes, with the sender's own RTT kept as a fallback and an RTCP-reported round trip behind that. Unknown latency is still 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.6.1 #
Quality telemetry stops polling an endpoint that rejects every request.
Found in production logs: 995 rejected quality reports in a single day โ 29% of all
quality traffic โ every one from a Flutter client sending no Authorization header
at all.
The privileged in-room routes authenticate with a per-user bearer, and that bearer only
lands when the host app hands its token response to adoptTokenResponse (apps using
this kit's own generateToken get it automatically). An app that mints tokens on its
own backend and skips that call had no bearer โ so the reporter posted into a 401
every ten seconds, for the whole session, forever.
Two costs, both silent:
- The telemetry was empty. Packet loss, jitter and RTT only exist client-side; if the report never lands, nobody has those numbers โ which is exactly the data an integrator would go looking for when a room sounds bad.
- Users paid for it. A request every ten seconds out of a phone's battery and metered data, with a guaranteed-zero result.
The reporter now checks for a bearer before starting, and logs one clear line naming
the call to make instead of failing quietly on a timer. UTDApiClient.hasUserToken is
exposed so an integrator can assert the same thing in their own tests.
If you mint tokens on your own backend, check your integration โ the bearer gates every privileged route, not just this one: kick, ban, role changes and seat management go through the same door.
3.6.0 #
An hour in an audio room now costs a listener about half what it did.
This is the room type most users are in, on metered data, for hours at a time โ and two things were costing them for nothing. Neither was visible from the code, because both were engine defaults this kit simply never set.
Audio publishes as speech, not music #
Unset, the engine falls back to its 48 kbps music preset. That is what this kit shipped โ for a room that carries nothing but people talking. Opus carries a voice at 24 kbps mono with no audible loss.
In a room with eight speakers this is the difference between roughly 200 MB and 100 MB an hour of a listener's mobile data.
DTX (stop sending during silence, which a seat room mostly is) is on by default and is now set explicitly, so a default moving underneath us fails a test instead of quietly doubling everyone's data.
Remote video is never subscribed #
autoSubscribe defaults to true and cannot filter by kind, so this kit subscribed to
any video track anyone published into an audio room โ allocating a transceiver and a
decoder for a picture it has no widget to render. Video subscriptions are now dropped
the moment they appear.
This helps the sender too: the SFU stops forwarding a layer nobody is subscribed to, so refusing the subscription also stops the upload.
Turning autoSubscribe off wholesale would have taken the audio with it โ which is
the entire room. A test pins that distinction so nobody reaches for the blunt fix
later.
The camera trap is closed #
UTDMediaController exposes setCameraEnabled / toggleCamera and any host app can
call them, but this kit set no capture or publish options. A camera turned on inside an
audio room would have published the engine default โ 720p at 30 fps with a full
simulcast ladder, about 2.3 Mbps. It is now capped at 180p / 15 fps / 120 kbps with
simulcast off, and uses hardware H.264 rather than software VP8.
No UI in this kit turns a camera on. This is a ceiling, not a feature.
Migration #
Nothing to change. If your app deliberately published or rendered video inside an audio room, it will no longer receive remote video here โ use the live room kit for that.
3.5.0 #
Crowded rooms cost what a small room costs. Every participant event โ a join,
a leave, an avatar change โ used to rebuild the whole room: jsonDecode over every
participant's metadata, a new object and a copied attribute map for each. One person
walking in paid for all 300. Filling a room to 300 cost 1+2+โฆ+300 โ 45,150
operations, quadratic in room size and paid in bursts exactly while the room filled.
The log line itself called participants.length, so every event paid it twice.
A participant index now absorbs each event into a single entry. Measured, same room:
fill a room of 300 45,150 โ 300 decodes, 45,150 โ 300 builds
one avatar change 300 โ 0 decodes
mute state ~1,000 reads/sec โ 22 on a mic event
- Mic state is event-driven. The 300ms sweep of every participant is gone; the
index rides
TrackMuted/TrackUnmuted(already being received) and tracks only the identities whose mute icon is actually rendered โ the local user and the seat occupants. A 3s recovery pass over those same identities covers a dropped event, and it does not run while disconnected or backgrounded. - Seat enrichment is a lookup. It scanned the full remote list once per occupied seat โ 22 ร 300 โ 6,600 comparisons per seat rebuild.
- The seat grid stops repainting for changes that do not affect it. The roles map keeps its identity unless a role really changed, so an avatar change or a mic toggle no longer repaints 22 tiles.
- Bursts collapse. 50 people arriving at once notify listeners once, not 50 times.
- 169 hot-path log calls no longer run in release.
debugPrintis not stripped from release builds;utdLogtakes the message as a closure, so the string is never even built outside debug.
Public API unchanged.
3.4.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.
3.3.0 #
Invitation windows + stale-snapshot guard. No breaking changes.
- Speaker invitations can expire. An ignored invitation used to stay open
forever.
UTDAudioRoomConfig.invitationTimeout(orexpiresInon a singleinviteToSpeak) sets how long it stays acceptable โ a product decision, so the app owns it; the engine clamps it to 10s..10min, refuses a late accept, and tells BOTH sides when the window closes. The built-in dialog closes itself at the deadline instead of offering a button that can only fail, andonSpeakerInvitationExpiredreports it. Default staysnull= no expiry, so upgrading changes nothing until the app opts in. - A room no longer flips to the default layout right after rendering. Seat
snapshots do not arrive in order โ the token response renders first, then
the SDK serves the
_seatsmetadata it read at connect, which is older. Every snapshot now carries a monotonic revision and anything older than what is already applied is dropped. Snapshots from an engine that sends no revision still apply.
3.2.0 #
Batch seat locking + invitation-flow repair. No breaking changes.
lockAllSeats()/unlockAllSeats()(controller +UTDSeatApi) โ host/admin locks or reopens every seat in ONE atomic server call with one broadcast. The engine owns the skip rules: an occupied seat keeps its speaker, an already-locked seat is not re-locked, the reserved host seat is never touched; unlock-all leaves open seats alone. Returns the number of seats actually changed.- Re-inviting to the mic works. A pending invitation used to answer 409 and permanently block the host from re-inviting anyone who missed the dialog (app backgrounded, message dropped) until that user left the room. The engine now SUPERSEDES the pending invitation โ same id, new seat/inviter, the dialog message is re-sent.
- An invitation accept that cannot seat you is no longer silent. The old
flow swallowed every failure, blind-fell-back to
takeSeat, and opened the mic unconditionally. Now: the mic opens only once a seat is actually held, an expired invitation (404) or lost seat (409) surfaces through the newonInvitationFailedcallback (UTDInvitationFailure.expired/.seatUnavailable/.unknown), and the default widget shows a localized snack (invitationExpired/invitationSeatTakenstrings, EN + AR).
3.1.0 #
New moderator-list API + bundled fixes. No breaking changes.
UTDRoomController.roomAdminsโ a render-ready list of the moderators currently in the room: host first, then admins, each carrying the app's own published attributes (avatar, frame, level, any custom keys) plusname,role,isHostand anavatarUrlconvenience. Built live from room state โ no server call; refresh onroleChangesStream/participantsStream.- The volume button now actually mutes the room. Under the media profile neither platform routes a room to the earpiece, so speaker-off was a no-op with a lying icon. It now silences all remote audio (and keeps silencing newly joining speakers), clears on a room switch, and matches the live kit's long-standing semantic.
- The media audio profile is claimed at controller construction โ the
native engine is built once per process by the first thing that touches it,
so the kit now claims the profile at the earliest entry point instead of at
connect. 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.
3.0.3 #
iOS listener audio fix โ no breaking changes. Update from 3.0.2 is required on iOS 26.
- An iPhone listener on iOS 26 hears the room again. 3.0.2's listener
recipe asked for a
playbacksession WITHallowBluetoothA2DP+allowAirPlayโ options AVAudioSession only accepts onplayAndRecord. iOS 26 rejects the wholesetCategorycall (OSStatus -50), so the session was never configured or activated: audio arrived (device telemetry showed ~40 kbps Opus, zero packet loss) and the device played none of it. The options are now gone โ on an output-only category Bluetooth A2DP and AirPlay route implicitly, so nothing is lost. The options set stays explicitly empty so WebRTC's ownplayAndRecord-only defaults cannot leak into the playback session. - New guard test: every recipe's options are checked against what AVAudioSession actually accepts for its category, for every track state โ not just the one option that bit us before.
3.0.2 #
iOS audio session fix โ no breaking changes.
- An iPhone listener can hear the room again. On iOS the kit forced the
audio session through flutter_webrtc's
setSpeakerphoneOnButPreferBluetooth, which sets WebRTC's own call profile โplayAndRecord+voiceChat+setActiveโ on every join, listeners included, discarding the media session this kit builds.playAndRecordneeds microphone permission, so a listener who never granted it had the session fail to activate and heard NOTHING; going up on the mic made it work, which is exactly how it was reported. - Coming off the mic no longer leaves the room on call audio. The same call
left the session in
voiceChat, the telephony mode: earpiece routing at call volume. The media recipe (playbackfor listeners,playAndRecord+videoChat+defaultToSpeakerwhile publishing) now owns the session on iOS, and Bluetooth comes from its own per-state options. - Android is unchanged: under
MODE_NORMALthat helper is what re-enables device selection, so it still runs there โ now behind a single guarded call site so it cannot drift back onto the iOS path. - Joining a room no longer inherits "sound off" from the previous one.
The controller is reused across rooms (a room switch, restore-from-minimize)
and
isAllRemoteAudioMutedsurvived the move. It is re-applied to every newly subscribed remote mic track, so the user joined a new room and heard NOBODY, on any platform, with nothing on screen to explain it. An admin's mute (mutedByAdmin) leaked the same way and kept refusing the user's own unmute in a room its moderator never touched. Both are now cleared when the room actually changes โ a reconnect into the same room still keeps what was set there. - The seat sheet no longer offers Lock on a reserved seat. The engine
refuses any lock on a seat with
reserved_forset (403, "Cannot lock a reserved seat"), and the room owner's seat always has it โ so an admin tapping Lock there always failed. Measured on production: 145 refused locks in a day across seven projects, all on seat 0. Unlock stays available, since a seat locked before it was reserved still has to be releasable.
3.0.1 #
Seat fixes โ no breaking changes.
- Leaving a seat no longer fails when the server already freed it. The
engine answers
POST /seats/leavewith404 Not seatedwhenever the seat is already free โ after a reconnect (the participant_left webhook frees it), after the server-side seat reconciler, or on a second tap. The kit reported that as a failure, so the host app showed an error AND kept the stale seat on screen: the user tapped again, failed again, and could not get off the seat. Measured on production before the fix: 224 of 948 leave calls (24%) answered- It is now treated as the goal state, and the grid resyncs.
- Changing a seat no longer fails on a no-op.
409 Already on this seat(a double tap, or a broadcast not yet applied) is likewise treated as success with a resync. The other 409s โSeat is locked,Target seat was takenโ still fail, since those are refusals the user needs to see.
3.0.0 #
Admin join fix โ admins connect as admin from the first frame โ plus
built-in audio quality telemetry.
-
Quality telemetry (new). The kit now samples WebRTC
getStats()every 15s and posts one audio snapshot per interval to the engine (POST /api/v1/quality): bitrate, packet-loss fraction, jitter, and RTT while publishing. This feeds the engine's per-room quality dashboard (SLA/MOS), which had no media-side data source before. Zero configuration, entirely best-effort (a failed report is dropped, never retried), and the payload is a few hundred bytes per interval. -
The admin role is now resolved BEFORE the token is minted. When the kit mints its own token and the best-known role would be
audience, the join awaitsadminIdsResolver(capped at 5s) and mints the token with the resolved role. The engine grants server-side RTC permissions at issuance โ no post-connect upgrade, no reconnect. A resolver slower than the cap (or throwing) falls back to anaudiencejoin instead of blocking on a degraded backend. -
Removed the post-connect self-upgrade + silent reconnect. The old path (join as
audience, thenPUT /participants/{self}/roleclaiming the owner as actor, then a full token re-issue + reconnect when that failed) could never succeed under server-signed bearer auth โ the engine ignores the client-asserted actor โ so every admin join ended in a forced reconnect: an audible audio cut for the admin and a join/leave flicker for the whole room. Measured on production before the fix: ~5,200 forced reconnects per day. -
BREAKING โ
UTDRoomController.upgradeSelfRoleis removed. Only breaking if you called it directly; the widget no longer needs it and there is no legitimate client-side path for a participant to change its own role.changeRole(owner-only) is unchanged. -
If your app passes a
tokenProvider(server-signed setups): the resolver is not consulted โ your backend decides the role when it mints the token, so make sure it mints admins withrole: 'admin'directly.
2.1.0 #
Seat-layout occupant policy โ no breaking changes.
setupSeatsnow acceptsoccupantPolicy("clear" | "retain" | "compact"): control what happens to current speakers when the seat layout or count changes.clear(default, and the behaviour when omitted) takes everyone off the seats;retainkeeps everyone whose seat index still exists and removes only those whose seat disappeared on shrink;compactadditionally slides displaced speakers onto free eligible seats within the new count, removing only those left without a seat. Seat 0 stays reserved for the room owner and is never a slide destination for anyone else.- Requires an engine build from 2026-08-21 or later for the new values; older engines return 422 for them. Omitting the parameter keeps the old behaviour on every engine version.
2.0.2 #
Speaker audio quality fix โ no breaking changes.
- Echo cancellation / noise suppression / auto gain control now actually run
for on-seat speakers. The media-mode setup used to flip the engine's
bypassVoiceProcessingswitch, which (besides picking the media session profile) also forced every capture constraint off โ so speakers published a raw, unprocessed microphone: echo of the room mix, background noise, no gain levelling. The kit now usesutd_media_client2.8.0'smediaAudioProfileengine mode: the session/routing behaviour is unchanged (media volume, loudspeaker, no OS "in call" state, no OS voice-processing unit) while WebRTC's software AEC/NS/AGC stay enabled on the mic. - Requires
utd_media_client^2.8.0.
2.0.1 #
Battery/network fixes โ no breaking changes.
- Background video pause. The app-lifecycle watcher now also disables any remote video publications while the app is backgrounded and re-enables them on resume (a no-op in a pure audio room โ it only matters when a host app runs stage/guest video through this kit). Audio keeps playing in the background by design, and Android OS Picture-in-Picture keeps its video.
2.0.0 #
Includes every fix from the 1.11.0โ1.14.0 maintenance line, ported onto the new engine:
- Audio rooms no longer put the phone "in a call" โ the room runs in the
MEDIA/playback profile (
UTDAudioMode.enableMediaMode(), installed before every connect): media volume, loudspeaker default, no system in-call state, other apps (e.g. WhatsApp voice notes) keep microphone access. Bluetooth routing re-asserts the media config instead of re-arming the call profile. - Self-mute vs admin-mute are now distinct โ new
UTDMediaController.mutedByAdminnotifier, set only by a host/admin_force_mute; self-unmute is refused while it holds;applyAdminUnmute()clears it.mutedParticipantsunchanged (still track-derived, for badges). - No more system chat announcements for comment lock/unlock and admin
role changes (they rendered as broken bubbles in custom message widgets).
The
announceRoleChangesflag and the related strings are gone. - Live cosmetic updates โ seat avatars/frames repaint mid-session via
registerCosmeticKeys+ reliable_cosmetic_updatefan-out, instead of waiting for a (unreliable) SFU attribute echo. - Android OS Picture-in-Picture is actually armed on connect/reuse
(
pip.armIfEnabled()), swapping to the compactUTDPipViewin PiP.
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 (seat/speaker/ban/role/comment) and remembers the RTC edge URL for prewarm โ exactly whatgenerateTokendoes as side effects. Idempotent.UTDAudioRoom.tokenProvidernow supports externally-minted tokens. The widget adopts the provider's response on its own controller, so a server-signed token authenticates ALL in-room operations (previously only the media connect worked and moderation calls hit 401). The constructor assert that required the provider to come fromgenerateTokenon the same controller is removed.
1.10.1 #
Bugfix.
Speaking ring #
UTDSpeakingRingno longer shrinks the avatar while the occupant is speaking. The pulsing ring previously insetting the child by its animating border width, so the seat image visibly shrank and pulsed smaller on every open mic. The ring now paints just OUTSIDE the avatar (BorderSide.strokeAlignOutside) and the avatar keeps a constant size. The defaultUTDSeatWidgetoccupied-seatStackis nowClip.noneso the outward ring/glow isn't clipped.
1.10.0 #
Dev-tunable seat sizing, snappier minimized overlays, and a polished pre-connect state. All additive and backward compatible โ apps that pass nothing are unaffected.
Seat layout #
- New
UTDSeatLayout(avatarRatio,rowPadding,rowSpacing,seatScale), passed viaUTDAudioRoomConfig.seatLayout. The mounted room publishes it to a shared source so every seat-size consumer (grid, skeleton,computeSeatSizecall sites, and the app-side metrics) reads the same values and can't drift. Defaults reproduce the original look; the 52โ120px safety clamp still applies as a hard rail.
Minimized overlay & PiP #
- Speaking state is now event-driven off
activeSpeakersinstead of a 1s pollingTimerโ the ring reacts instantly and nothing ticks while the room is silent. - Wave animations run only while someone is speaking, so a silent minimized session holds zero per-frame animation work.
- Overlay bubble snaps to the nearest edge on release (with flick support), clamps clear of system chrome so it can't tuck behind the notch/home indicator, lifts on grab, and adds haptic feedback on drag/restore/close/mic actions.
Connecting state #
- The static pre-connect placeholder is now a gently breathing skeleton driven by a
single shared
AnimationController, wrapped in aRepaintBoundary, torn down the instant the real room paints, and honoring reduce-motion.
1.9.0 #
Room renders at token receipt โ occupied seats with names and avatars appear roughly one token round trip after the tap, instead of waiting for the full RTC connection (WSS + ICE/DTLS). On a prefetched token the room is effectively instant.
Rendering #
- The room body (seats, chat, controls) now paints as soon as the token response arrives
(
_tokenReady). The grey skeleton only gates the ban check (token POST 403), not the entire RTC establishment. A connect failure drops back to the skeleton/error view. - Seats are seeded pre-connect via
UTDRoomController.primeSeats: newer engines embed aseatssnapshot in the token response (zero extra round trips); against older engines the kit firesGET /seatsin parallel with the RTC dial. - Seat mutations (take/move) are gated on connection state so a tap during the pre-connect window cannot create ghost occupants.
Avatars #
UTDDefaultAvatarnow usescached_network_image(disk cache) โ avatars download once per install, not once per session. Keyed by occupant so a seat that changes hands shows the new person's initials, not the old photo.MediaQuery.devicePixelRatioOfreplaces the fullMediaQuery.ofto avoid rebuilding every avatar on each keyboard-inset animation frame.
Seat updates #
- Seat mutation responses from newer engines carry the synced seat
stateinline; the kit applies it immediately (applyMutationResponse, generation- and room-guarded) so the actor sees changes without waiting for the_seat_updatebroadcast round trip. - Blank occupant name/avatar self-heals from the occupant's live RTC participant attributes
(
_withLiveAttributes+ aparticipantAttributesChangedrepair listener). The per-key merge never un-fills existing attributes. - The preservation guard treats an all-empty-string attribute map (server enrichment failure) as effectively empty, so it no longer blocks the live-attribute fill.
Host join #
audioSetup()(mic capture, Bluetooth routing, setAttributes) now runs unawaited for all joins, including hosts withturnOnMicrophoneWhenJoining. The ordering is internal to the closure; the mic permission dialog now appears over the rendered room instead of behind a skeleton. The_opEpoch/cancelPendingPublishmachinery handles aleave()during setup.- Same-instance reconnect (
connect()re-dial) now drains pending mic publishes before disconnecting, closing theaddTransceivertrack-is-null window. - Device-info collection is bounded at 150 ms; a timeout sends nulls (the engine treats
all device fields as optional).
deviceIdstays fully awaited (single-session enforcement).
Reliability #
- Inter-attempt teardown in the retry loop is bounded at 2 s (was unbounded โ the SDK's disconnect can block ~10 s per attempt on a half-dead link).
connectTimeoutraised 15 s โ 25 s (deadlock backstop aligned with the SDK's 3 ร 7 s internal phase budget). The reconnection handler's force-exit is suspended during a kit-driven connect so it cannot fire mid-retry.
Jank cluster #
- Payload
debugPrints (data messages, seat metadata) gated tokDebugMode. - The seat grid binds
participantRolesNotifier(cached, updated on participant/role changes) instead of theparticipantRolesgetter that jsonDecodes every participant's metadata on every rebuild. MediaQuery.sizeOf/devicePixelRatioOfreplaceMediaQuery.ofin the seat grid, skeleton, seat widget, and avatar โ eliminates full-subtree rebuilds on keyboard frames.UTDSpeakingRingkeeps a structure-stable widget tree (AnimatedBuilder โ SizedBox โ DecoratedBox โ Padding โ child always), so the avatar subtree is never torn down and re-inflated on speaking transitions.
README #
- New "Fast first join" section documenting
warmUp()and the tap-time token prefetch pattern for apps that want the first entrance to be as fast as every later one.
1.8.0 #
Faster room entry โ every serial cost on the join path was removed, overlapped, or cached:
- HTTP transport is now process-shared and survives room exit โ re-entry, so rejoins reuse the
warm TLS connection to the engine instead of paying a fresh DNS+TCP+TLS handshake per join
(~60โ200ms on mobile).
UTDApiClient.dispose()no longer closes the shared transport. - The token host's TLS connection and the device-id/device-info caches are warmed
fire-and-forget at
initApi()time, off the join's critical path. - New
UTDRoomController.warmUp()(static): apps can call it when a room entry becomes likely (e.g. the lobby screen opens) to pre-open the token-host connection, heat the device caches, and pre-warm DNS/TLS to the RTC edge โ making the subsequent join near-handshake-free. - The RTC edge URL from each successful join is remembered (memory + SharedPreferences) and the next join pre-warms DNS/TLS to it in parallel with its token request, so the WSS dial resumes a TLS session instead of running a cold handshake (~30โ150ms).
- Device-id and device-info lookups now run concurrently (and package/device platform channels inside the collector too), with in-flight memoization โ a cold first join pays one platform hop instead of three.
- On a room switch, the previous room's teardown starts as soon as the new join begins and overlaps the token round trip instead of serializing ahead of the RTC dial (~50โ300ms on switches). Note: the old room now starts closing even if the new join later fails.
- Flaky-network joins fail over faster: the SDK's signal phases are bounded at 7s (was 10s default, racing the kit's own 15s outer timeout), and the retry delay dropped 500ms โ 200ms.
1.7.0 #
- Internalized the real-time transport layer so it is no longer part of the public API. The
low-level engine handle (
UTDRoomManager) is no longer exported, andUTDRoomController.roomManageris now private. Consumers use the neutral surface โUTDAudioRoom,UTDRoomController, and the seat/media/chat controllers โ none of which expose transport-specific types.UTDConnectionStatemoved to its own file but is still exported unchanged. - Breaking (advanced API only): code that reached
controller.roomManageror the raw engine handle must migrate to the controller's public getters (e.g.localIdentity,connectionState,isConnected). The standard widget/controller usage is unaffected.
1.6.1 #
- Distinguish a not-activated service from a ban on the token endpoint. A non-ban
403(e.g.Type 'audio_room' 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). When the host supplies no
onConnectError,UTDAudioRoomnow falls back to this error view instead of an endless connecting skeleton. AddsUTDRoomStrings.serviceNotAvailable(EN + AR).
1.6.0 #
- Auto-collect device facts for the dashboard's per-participant view.
UTDRoomController.generateTokennow populatesdevice_model/os/os_version/app_versionautomatically (viadevice_info_plus+package_info_plus) when the host app doesn't pass them. Explicit arguments still win, collection is cached per process, and it never throws โ on an unsupported platform or a plugin failure each field degrades tonullrather than blocking token issuance. Values are capped to the engine's column limits (device_model 100, os 50, os_version/app_version 20). UTDRoomController.generateToken/UTDTokenApi.generateToken: add an optionalimageUrl, sent asimageonPOST /api/v1/tokenand shown as the participant's profile image in the dashboard.- New direct dependencies:
device_info_plus: ^12.0.0andpackage_info_plus: ^8.0.0(the former was already in the tree transitively via the real-time engine). - Minimum SDK raised to Dart
>=3.7.0/ Flutter>=3.29.0, required bydevice_info_plus ^12.0.0. Hosts on older toolchains should stay on1.5.0.
1.5.0 #
- Type-first token request: the kit now sends
type: 'audio_room'onPOST /api/v1/tokeninstead of the legacyservice: 'rooms'. The engine is type-first โ oneappId/appKeyserves every product type enabled on the project, and the type is a per-request field, not a credential. The project must haveaudio_roomin its enabled types. Seat behavior is unchanged โaudio_roomkeeps the full seat model (take/leave/switch/lock/unlock/kick/mute/swap, apply-to-speak, seat grid); only the token field changed. UTDRoomController.generateToken/UTDTokenApi.generateToken: the requiredserviceparameter is removed and replaced by an optionaltype(default'audio_room'). Drop-in users of theUTDAudioRoomwidget are unaffected โ it no longer passesserviceinternally. Direct callers ofgenerateTokenshould dropservice: 'rooms'; the default already targetsaudio_room.- Non-breaking on the engine side: the deployed engine still accepts the legacy
service+kindfields and derives the canonical type, so older builds of this kit keep working against the same engine while apps migrate to this version at their own pace.
1.4.0 #
- No-backend credentials (recommended): pass
UTDAudioRoom(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 itsUTDTokenProvider/UTDTokenRequest/UTDTokenBundletypes plus theUTDRoomController.usesTokenProvidergetter (added in 1.3.0). The no-backendappKeyflow replaces it โ there is no longer a built-in path for integrators who run their own token backend. - Removed
serverSecretentirely (deprecated in 1.3.0).UTDAudioRoom.serverSecret,UTDRoomController.initApi(serverSecret:)/ itsserverSecretgetter, and theUTDApiClient(appSecret:)param /X-App-Secretheader are all gone.appKeyis now the sole, required credential. Migrate anyserverSecret:callsites toappKey:. - A leaked
app_keycannot forge bearers offline or call the server-to-server API, and rotates independently via the engineregenerate-credentialsadmin endpoint.
1.3.0 #
- Secure credentials via
tokenProvider(recommended): a newUTDAudioRoom(tokenProvider: ...)/UTDRoomController.initApi(tokenProvider: ...)callback lets the integrator mint tokens from their own backend, so the projectserverSecretnever ships in the app. The kit calls the provider with aUTDTokenRequest(identity, room, service, room owner, device id) and consumes the returnedUTDTokenBundle(UTDTokenBundle.fromEngineJsonparses the enginePOST /api/v1/tokenresponse verbatim). The per-useruser_tokenfrom the bundle is applied as theAuthorization: Bearerfor all in-room/moderation REST calls, so actions are authenticated as the server-verified user. New exported API:UTDTokenProvider,UTDTokenRequest,UTDTokenBundle, plusUTDRoomController.usesTokenProvider. The role is intentionally not sent from the client in this mode โ the integrator backend is the authority on the user's role. serverSecretis now deprecated and optional.UTDAudioRoom.serverSecret/initApi(serverSecret:)still work in legacy/dual mode (the kit keeps sendingX-App-Secret), but embedding the secret in a shipped app lets anyone extract it and mint tokens for any identity/room โ migrate totokenProvider.UTDAudioRoomnow asserts that exactly one oftokenProvider(recommended) orserverSecret(legacy) is provided.UTDTokenResponsegains auserTokenfield (engineuser_token; empty on legacy responses) so the per-user bearer is surfaced through the normal token flow as well.- README rewritten around the secure
tokenProviderflow, with a backend-proxy example and an explicit "do not ship the secret" warning; the params table marksappIdas a safe public identifier andserverSecretas deprecated/legacy.
1.2.0 #
- Comment lock: host/admin can now lock room chat so only host/admin may post.
New
UTDCommentApi(exported) wrapping the engine endpointsPOST /api/v1/rooms/{room}/comments/{lock,unlock}, plusUTDRoomController.lockComments()/unlockComments()/setCommentsLocked(), thecommentsLockednotifier and thecanICommentgetter. The lock is driven by the server (the_chat_lockbroadcast + thechat_lockedroom-metadata field), never set optimistically, so it stays consistent across devices and is restored for late joiners on reconnect. Enforcement lives in both the send path (a hidden composer can't be bypassed) and the receive path (chat from non-privileged senders is dropped while locked, as a backstop against a tampered client). The default controls bar gains a host/admin lock toggle and swaps the audience chat button for a lock indicator while locked. - Admin role announcements: a centered, dimmed room-chat system line is posted
when a user gains or loses the
adminrole (" UTDRoomController.stringslets the host app's localizedUTDRoomStringsback controller-emitted system lines; wired automatically fromUTDAudioRoomConfig. New strings (English + Arabic defaults) for the comment-lock UI and the admin/lock announcements; existing directUTDRoomStringscallers are unaffected (the new fields default to English).
1.1.0 #
- Split the API base URL by operation: token generation now uses the edge host
https://udt-stream.comwhile all in-room operations (seats, speakers, bans, roles) use the grey-cloud engine hosthttps://engine.udt-stream.com.UTDApiClient.defaultBaseUrlis now the engine host; the newUTDApiClient.defaultTokenBaseUrlis the token host.initApigained atokenBaseUrlparameter (defaulted) โ existing callers need no change. - Security (M2): the client no longer self-writes cosmetic fields (avatar, frame,
color name) into RTC participant metadata. Cosmetics are published only
as participant attributes; the server remains the sole owner of metadata
(
role,_device, โฆ). This removes a client-trust surface where a peer could spoofrole/VIP in broadcast metadata. No public API change โ cosmetics still flow throughuserAttributes/setAttributes. Part of a coordinated rollout: the backend may then gatecanUpdateOwnMetadatato privileged roles only.
1.0.1 #
- Update the default API base URL to
https://api.udt-stream.com.
1.0.0 #
- Initial standalone release. Extracted from the Tempo-Live monorepo into its own package repository.
- Real-time audio room: a drop-in prebuilt live-audio-room solution.
- Seat management (take, leave, switch, lock, unlock, kick, mute, swap), built-in seat actions and moderation sheet, apply-to-speak request queue, member list with host actions, mic/speaker controls (Bluetooth-preferring routing).
- Real-time chat over the data channel (batching + dedup), tiered reconnection, minimize / Android OS Picture-in-Picture, 8 layout modes.