utd_stream_sdk 0.2.0
utd_stream_sdk: ^0.2.0 copied to clipboard
Headless LiveKit-based audio/video SDK for Flutter (audio call, video call, live stream, audio room) as logic-only sessions with no UI — build your own interface.
UTD Stream SDK (headless) #
LiveKit-based audio/video for Flutter with no UI. You get the logic — sessions, reactive state,
and control methods — and build your own interface. One set of credentials (app_id + app_key)
works for every product type your project enabled; the type is chosen per session, never per
credential.
The SDK is server-authoritative: publish capability, seats, roles, and bans are all granted by the engine. The SDK mirrors that state and never elevates its own permissions, and no project secret ever ships in the app.
The four types #
| Type | What it is | Session |
|---|---|---|
audio_call |
1:1 audio | UTDCallSession |
video_call |
1:1 audio + video | UTDCallSession |
live_stream |
one host, unlimited viewers, engine-uncapped co-publishers | UTDLiveStreamSession |
audio_room |
seat-based speakers + listeners | UTDAudioRoomSession |
Install #
dependencies:
utd_stream_sdk: ^0.2.0
Platform setup #
Declare the media permissions the SDK needs (see example/ for a working setup):
| Platform | Required |
|---|---|
| iOS | NSMicrophoneUsageDescription, NSCameraUsageDescription in Info.plist |
| Android | RECORD_AUDIO, CAMERA, INTERNET, MODIFY_AUDIO_SETTINGS, BLUETOOTH_CONNECT (API 31+) |
| macOS | com.apple.security.device.audio-input / .camera / .network.client entitlements + the usage strings |
| Web | none (the browser prompts) |
Quick start #
final client = UTDStreamClient(appId: '1234567890', appKey: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6');
// ── Audio room ──
final room = await client.joinAudioRoom(identity: 'u1', roomName: 'lobby', asHost: true, seatCount: 8);
await room.takeSeat(0); // engine grants publish; the SDK waits for the grant, then unmutes
room.seats.addListener(rebuildUI); // reactive UTDSeatGrid
// ── Live stream ──
final live = await client.joinLiveStream(identity: 'host1', roomName: 'show', asHost: true);
await live.goLive(); // host publishes camera + mic
await live.inviteToStage('u2'); // promote a viewer to co-publish — no engine count limit
// viewers join as `audience` (subscribe only) and can raise a hand: await live.requestStage();
// ── 1:1 call ──
await client.authenticate(identity: 'u1'); // mint a bearer (calls have no room to mint first)
final call = client.callSession(UTDStreamType.videoCall);
final created = await call.start(calleeIdentity: 'u2');
// callee side: client.authenticate(identity:'u2'); await call.accept(created.callId);
State: two ways to consume it #
Every session exposes the same state reactively (ValueListenables + a raw events stream) and
imperatively (one assignable event handler). Use whichever fits — or both.
// Reactive
ValueListenableBuilder(valueListenable: room.connectionState, builder: (_, state, _) => ...);
room.seats.addListener(rebuildUI);
// Imperative (zego-style assignable callbacks, per session)
room.setEventHandler(UTDStreamEventHandler(
onConnectionStateChanged: (s) => ...,
onSeatUpdate: (grid) => ...,
onSpeakerEvent: (e) => ...,
onForceExit: (reason) => leaveScreen(reason),
onError: (e) => report(e),
));
Listenables available on every session: connectionState (UTDConnectionState), connected,
canPublish, micEnabled, cameraEnabled, speakerOn, screenShareEnabled, remoteParticipants,
activeSpeakers. Audio rooms add seats (UTDSeatGrid), chatLocked, roles; live streams add
stage; calls add status and callId.
Connection lifecycle & reconnection #
UTDConnectionState is disconnected → connecting → connected → reconnecting → forceExit. LiveKit
auto-reconnects; the SDK runs a tiered re-sync (short outage → light seat re-sync, longer → full
re-sync, past the window → force-exit) and re-reads room state from metadata + REST so your UI is
never left stale. A failed/aborted connect can never orphan a live audio session.
A force-exit funnel delivers server-driven removals exactly once — ban, single-active-session
takeover (signed_in_elsewhere), kick, or an unrecoverable reconnect — via session.forceExit /
onForceExit.
Device control & audio routing #
await room.setMicrophone(true); // gated on the server publish grant
await room.setSpeakerPreferBluetooth(); // prefer a BT headset, else loudspeaker
await room.applyBluetoothAudioRouting(); // Android fix: call after connect + publish
await room.switchCamera();
await room.setScreenShare(true);
room.muteAllRemoteAudio(true);
live_stream: roles & the stage #
live_stream is the seatless, unbounded broadcast (seat_count / seat_mode / host_seat are
ignored; the engine creates the room uncapped). Your app decides how many co-publisher tiles to
surface from UTDLiveStreamSession.stage, which updates reactively from _stage_update.
| Role | Publishes? | Moderates? | How you get it |
|---|---|---|---|
host |
yes | yes | the verified room owner (asHost: true) |
guest |
yes | no | a host-invited co-publisher (inviteToStage) |
admin |
no | yes | a host-promoted moderator — never on camera |
audience |
no | no | the default for everyone else |
Roles are server-authoritative: a non-owner's claimed role is clamped to audience, so a viewer
can't self-grant publish or moderation. Moderator promotion is owner-only via
session.moderation.changeRole(id, UTDRole.admin).
audio_room: seats #
seats is a reactive UTDSeatGrid kept fresh from _seat_update + room metadata, with a REST
re-sync on reconnect (guarded so a slow fetch can't clobber a fresher push). Speakers:
await room.takeSeat(1); // free mode
await room.requestToSpeak(); // request mode → host approves
await room.approveSpeaker(requestId);
await room.inviteToSpeak('u9', seatIndex: 2);
// host seat ops: moveSeat, kickSeat, lockSeat/unlockSeat, muteSeat/unmuteSeat
Moderation #
Host/admin operations are server-verified (session.moderation):
await room.moderation.ban('u9', reason: 'spam', durationSeconds: 3600);
await room.moderation.kick('u9');
await room.moderation.changeRole('u9', UTDRole.admin);
await room.moderation.lockComments();
await room.moderation.forceMute('u9');
Realtime: presence, incoming calls & chat (WebSocket) #
Presence, incoming-call push, and DM/group chat run over the engine's signalling WebSocket. Its token is minted with the project server secret, so — to keep the no-secret-on-client guarantee — your backend mints it and the SDK consumes it:
final signal = client.signalClient(
credentialsProvider: () async {
final res = await myBackend.mintSignalToken(); // your server calls POST /api/v1/signal/token
return UTDSignalCredentials(token: res.token, url: res.url);
},
);
await signal.connect();
signal.incomingCalls.listen((inv) => showIncomingCall(inv)); // the callee finally gets the call_id
signal.presenceChanges.listen((p) => updateDot(p.identity, p.status));
signal.messages.listen((m) => appendMessage(m));
await signal.subscribePresence(['u2', 'u3']);
await signal.sendMessage(toIdentity: 'u2', content: 'hi');
final invite = await signal.inviteCall(calleeIdentity: 'u2', type: UTDStreamType.videoCall);
The provider is re-invoked on every reconnect, so expired tokens refresh transparently.
Error handling #
All failures surface as UTDStreamException, with subtypes you can branch on: UTDBannedException
(403), UTDRateLimitedException (429, with isTakeoverCooldown), UTDTokenException (malformed
mint). Each carries a stable UTDErrorCode.
Architecture #
UTDStreamClient— entry point; holds credentials, mints tokens, produces sessions + the signal client.UTDApi/UTDApiClient— engine REST (mint host usesX-App-Key; privileged ops use the server-mintedAuthorization: Bearer). No secret ever ships in the app.UTDRoom— headless LiveKitRoomwrapper: lifecycle, reconnection, the decoded data plane, publish gating, audio routing.UTDDataRouter— decodes the engine's data-channel messages into typed state.UTDSignalClient— the realtime WebSocket plane (presence / calls / chat).session/*— one control surface per product type;UTDModerationfor host ops.
License #
MIT — see LICENSE.