UTD Audio Room Kit
A Flutter package for building live audio-room experiences, powered by a real-time audio engine and the UTD Stream Engine. It is a drop-in prebuilt live-audio-room solution: give it the required data and you get a fully interactive room — seats, chat, mic/speaker controls, seat actions, member list, speak requests, and moderation — with no extra UI code.
Features
- Drop-in room UI — a complete, interactive room from required data alone.
- Seat management — take, leave, switch, lock, unlock, kick, mute, swap.
- Built-in seat actions — tap a seat to take/leave it; host/admin get a moderation sheet (mute, kick, lock, invite).
- Speak requests — audience apply-to-speak; host approve/reject queue with a live badge.
- Member list — participants with host actions (mute, kick, invite, ban, promote/demote).
- Media controls — microphone & speaker with reactive state (Bluetooth-preferring routing).
- Real-time chat — data-channel messages with batching and dedup.
- Reconnection — tiered: light sync (<15s), full sync (<60s), default force-exit (>60s).
- Minimize / PiP — minimize to a floating overlay; optional Android OS Picture-in-Picture.
- Theming & i18n —
UTDRoomTheme(colors) +UTDRoomStrings(EN/AR built in) without replacing widgets. - Full customization — replace any section (header, messages, controls, background, seats) with your own widgets.
Installation
dependencies:
utd_audio_room_kit:
path: packages/utd_audio_room_kit
Audio engine setup (required)
The native media engine is built once per process, and the first thing
that touches it decides its audio profile. Claim the media profile before any
screen runs — first line of main():
import 'package:utd_media_client/utd_media_client.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await UtdmClient.initialize(mediaAudioProfile: true);
runApp(const MyApp());
}
Without this, any earlier WebRTC touch (a camera preview, a video renderer,
device enumeration) locks the process into the call profile — earpiece
routing, call volume — and no later call can undo it. If you ever see
[UtdmClient] the media engine was already initialized in the console,
something ran before this line; report it to us with the log.
Platform setup
iOS — add to ios/Runner/Info.plist:
<key>NSMicrophoneUsageDescription</key>
<string>Required for audio room participation</string>
For background audio + PiP, enable Background Modes → Audio, AirPlay, and Picture in Picture in Xcode.
Android — add to android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
Set minSdkVersion to at least 21 (31+ for OS PiP).
Credentials — pass your appKey (no backend required)
Ship
appId+appKey(the project's publishable app key, from the UTD console). The kit mints tokens directly from the engine withX-App-Key; the engine signs the returned per-useruser_tokenwith the projectserver_secretserver-side, so the secret never ships in the app. The kit then uses thatuser_tokenas theAuthorization: Bearerfor all in-room calls. No token server to stand up.
appKey is not the server_secret: it can't sign tokens or call the server-to-server API, and
it can be rotated on its own from the console. Treat a leak as a project-scoped incident (rotate +
ship a new build).
Type-first engine. The engine groups products by a per-request
type, not by credential — oneappId/appKeyworks for every type enabled on the project. This kit always mintstype: 'audio_room'onPOST /api/v1/token; the project just needsaudio_roomin its enabled types (set per project in the UTD console). The seat model below is theaudio_roommodel — unchanged. (Live video broadcasts are a separatelive_streamtype served by the standalone live kit; this package does not use it.)
Quick start (drop-in, no backend)
import 'package:utd_audio_room_kit/utd_audio_room_kit.dart';
UTDAudioRoom(
appId: '<utd-app-id>',
appKey: '<utd-app-key>', // publishable; the kit mints tokens itself, no backend needed
userId: 'user123',
userName: 'John Doe',
roomId: 'room456',
roomOwnerId: 'owner789', // userId == roomOwnerId ⇒ this user is the host
);
With only this, the package renders: a dark background, the seat grid (avatars + names), a header (minimize + exit), the chat list, and a role-aware controls bar. Tapping a seat takes/leaves it; audience can apply to speak; host/admin get member-list, the speak-request queue, and per-seat moderation. A connect failure shows an error + retry instead of an endless spinner.
The host is detected as
userId == roomOwnerId; identities inadminIdsare treated as admins. By default a host/admin joins mic-on (autoHostMic) and is auto-seated onhostSeatIndexif the backend didn't seat them (autoSeatHost).
Fast first join (recommended)
The room body and seats render as soon as the token arrives — the RTC connection finishes in the background. That makes the token round trip the only thing standing between the tap and a populated room, so two one-time integrations make the FIRST entrance of a session as fast as every later one:
1. Warm up when a join becomes likely (e.g. the rooms lobby opens). The very first request of a process otherwise pays DNS + TLS to the token host inline (~100–300 ms on mobile networks), plus cold device caches:
// Fire-and-forget; safe to call repeatedly (everything is cached).
UTDRoomController.warmUp();
2. Prefetch the token at tap time, so the round trip overlaps the route navigation instead of starting after the room screen mounts:
final controller = UTDRoomController()
..initApi(appId: appId, appKey: appKey)
..registerModes(myModes);
final isHost = userId == roomOwnerId;
final seatParams = UTDAudioRoom.resolveTokenSeatParams(
layoutMode: layoutMode, isHost: isHost, modes: myModes);
final tokenFuture = controller.generateToken(
identity: userId, roomName: roomId, roomOwnerId: roomOwnerId,
role: isHost ? 'host' : 'audience', name: userName,
seatCount: seatParams.seatCount, seatMode: seatParams.seatMode,
hostSeat: seatParams.hostSeat, modeId: seatParams.modeId,
);
// ...navigate, then pass BOTH to the widget:
UTDAudioRoom(
// ...,
controller: controller,
tokenProvider: () => tokenFuture,
);
Avatars are disk-cached after the first download, so only a fresh install pays for
them once. Serve resized CDN thumbnails (e.g. ?w=128) in the avatar URLs — the kit
caps the decode size but cannot shrink the bytes on the wire.
Constructor
| Param | Required | Description |
|---|---|---|
appId |
✅ | UTD Stream Engine app id (public identifier, safe to ship) |
appKey |
✅ | Publishable app key — the kit mints tokens directly from the engine (no backend). |
userId |
✅ | Local user identity |
userName |
✅ | Display name |
roomId |
✅ | Room to join/create |
roomOwnerId |
✅ | Owner identity (host detection + owner-only actions) |
adminIds |
– | Identities to request the admin role for |
layoutMode |
– | Mode id to use (default '3' = 9 seats); see Layout modes |
modes |
– | Custom UTDRoomModes to register |
config |
– | UTDAudioRoomConfig (see below) |
controller |
– | Reuse an existing UTDRoomController (e.g. after minimize) |
onControllerReady |
– | Get the controller (wire navigatorKey, minimize, etc.) |
onConnectionChanged |
– | (bool isConnected) |
onSeatTap |
– | Override the built-in seat action sheet |
onSeatChanged |
– | (List<SeatState> seats) |
onConnectError |
– | Override the built-in connect-error view |
Configuration (UTDAudioRoomConfig)
UTDAudioRoom(
// …required data…
config: UTDAudioRoomConfig(
// behavior
showControlsBar: true,
showSeatNames: true, // render occupant names under seats
enableMinimize: true,
turnOnMicrophoneWhenJoining: false,
useSpeakerWhenJoining: true,
hostSeatIndex: 0,
autoHostMic: true, // host/admin joins mic-on
autoSeatHost: true, // host auto-takes hostSeatIndex if empty
useDefaultBackground: true, // paints theme.background when no backgroundWidget
// theming & strings
theme: UTDRoomTheme(), // or UTDRoomTheme().copyWith(primary: …)
strings: UTDRoomStrings.ar(), // null ⇒ English
// user attributes shared with others (name/avatar are read on seats)
userInRoomAttributes: {'avatar': 'https://…', 'name': 'John'},
// section overrides (null ⇒ built-in default)
headerWidget: null,
messagesWidget: null,
controlsBarWidget: null,
backgroundWidget: null,
foregroundWidget: null,
pkWidget: null,
// seat builders (null ⇒ built-in default avatar/name)
seatBuilder: null,
avatarBuilder: null,
emptySeatBuilder: null,
lockedSeatBuilder: null,
),
);
Every built-in default is off when you override it — supplying a controlsBarWidget,
avatarBuilder, onSeatTap, etc. fully replaces the corresponding default.
Theming & localization
UTDRoomTheme is an all-defaulted color set (dark by default, so the white default content
is never invisible). UTDRoomStrings.en() / .ar() cover every default label; pass your own
UTDRoomStrings(...) to translate or relabel:
config: UTDAudioRoomConfig(
theme: const UTDRoomTheme().copyWith(primary: Colors.teal, background: const Color(0xFF101010)),
strings: UTDRoomStrings.ar(),
);
Controller API
onControllerReady gives you a UTDRoomController for programmatic control:
final c = controller;
// Seats
await c.seatController.takeSeat(0, c.localIdentity!);
await c.seatController.leaveSeat(c.localIdentity!);
await c.seatController.lockSeat(2, identity: c.localIdentity!);
// Speak requests
await c.requestToSpeak();
await c.approveSpeakerRequest(requestId);
await c.inviteToSpeak(targetId, seatIndex: 3);
// Roles & bans (gated server-side)
await c.changeRole(targetIdentity: id, role: 'admin'); // OWNER-only (403 otherwise)
await c.banUser(id, reason: '…'); // host/admin-gated
// Media
await c.mediaController.toggleMicrophone();
// Reactive state
c.seatController.seats; // ValueNotifier<List<SeatState>>
c.seatController.pendingRequests; // ValueNotifier<List<SpeakerRequest>>
c.activeSpeakers; // ValueNotifier<Set<String>>
c.participantsStream; // Stream<List<UTDParticipant>>
Minimize / PiP
Wrap your app with UTDMiniPopScope and mount UTDMiniOverlayPage at the root, then
configure minimize in onControllerReady:
onControllerReady: (controller) {
controller.navigatorKey = navKey; // for default dialogs
controller.minimize.configure(
UTDMinimizeConfig(
enableOSPip: true,
onClose: () => myExitRoom(),
),
);
},
Layout modes
The package ships one default mode (id: '3', 9 seats, 3×3). Register custom layouts via
modes and select with layoutMode:
UTDAudioRoom(
layoutMode: 'party',
modes: const [
UTDRoomMode(id: 'party', seatCount: 8, rows: [[0,1,2,3],[4,5,6,7]]),
],
);
Architecture
UTDRoomController
├── (real-time engine) — connection & lifecycle (internal)
├── UTDSeatController — seat state (ValueNotifier) + speak requests
├── UTDMediaController — mic / speaker
├── UTDChatController — data-channel chat
└── UTDMinimizeController / UTDPipController
All state is exposed via ValueNotifier/Stream, so it integrates with any state-management
approach. See the Quick start snippet above for a minimal drop-in app.