utd_stream_sdk 0.2.1
utd_stream_sdk: ^0.2.1 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.
example/lib/main.dart
// Runnable example for the headless utd_stream_sdk.
//
// It demonstrates the three product surfaces — audio room, live stream, and a
// 1:1 call — building UI entirely from the sessions' `ValueListenable`s. The
// SDK ships no widgets; everything you see here is host-app UI reacting to SDK
// state, which is exactly how you would integrate it.
//
// Fill in your `appId` / `appKey` on the home screen before joining.
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart'
show VideoTrack, VideoTrackRenderer;
import 'package:utd_stream_sdk/utd_stream_sdk.dart';
void main() => runApp(const ExampleApp());
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'UTD Stream SDK',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
home: const HomePage(),
);
}
/// Collects credentials + identity and routes into each demo flow.
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _appId = TextEditingController();
final _appKey = TextEditingController();
final _identity = TextEditingController(text: 'user-1');
final _room = TextEditingController(text: 'demo-room');
final _callee = TextEditingController(text: 'user-2');
// STEP 1: build one client per signed-in user. The same credentials work for
// every product type the project enabled — the type is chosen per session.
UTDStreamClient _client() =>
UTDStreamClient(appId: _appId.text.trim(), appKey: _appKey.text.trim());
void _open(Widget page) =>
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
Future<void> _guard(Future<void> Function() action) async {
if (_appId.text.trim().isEmpty || _appKey.text.trim().isEmpty) {
_snack('Enter appId and appKey first');
return;
}
try {
await action();
} on UTDStreamException catch (e) {
_snack('${e.code ?? e.statusCode}: ${e.message}');
}
}
void _snack(String m) {
if (mounted)
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(m)));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('UTD Stream SDK — example')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_field(_appId, 'appId'),
_field(_appKey, 'appKey'),
_field(_identity, 'identity (this user)'),
_field(_room, 'room name'),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.event_seat),
label: const Text('Join audio room as host'),
// STEP 2a: join an audio room. The host can seed the seat grid.
onPressed: () => _guard(() async {
final client = _client();
final session = await client.joinAudioRoom(
identity: _identity.text.trim(),
roomName: _room.text.trim(),
asHost: true,
seatCount: 8,
);
_open(AudioRoomPage(client: client, session: session));
}),
),
const SizedBox(height: 8),
OutlinedButton.icon(
icon: const Icon(Icons.live_tv),
label: const Text('Go live (host)'),
// STEP 2b: join a live stream as host, then goLive() to publish.
onPressed: () => _guard(() async {
final client = _client();
final session = await client.joinLiveStream(
identity: _identity.text.trim(),
roomName: _room.text.trim(),
asHost: true,
);
_open(LiveStreamPage(client: client, session: session));
}),
),
const Divider(height: 32),
_field(_callee, 'callee identity'),
const SizedBox(height: 8),
OutlinedButton.icon(
icon: const Icon(Icons.videocam),
label: const Text('Start 1:1 video call'),
// STEP 2c: calls have no room to mint first, so authenticate() mints
// a bearer, then the call session start()s the call.
onPressed: () => _guard(() async {
final client = _client();
await client.authenticate(identity: _identity.text.trim());
final session = client.callSession(UTDStreamType.videoCall);
_open(
CallPage(
client: client,
session: session,
callee: _callee.text.trim(),
),
);
}),
),
],
),
);
}
Widget _field(TextEditingController c, String label) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextField(
controller: c,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
@override
void dispose() {
for (final c in [_appId, _appKey, _identity, _room, _callee]) {
c.dispose();
}
super.dispose();
}
}
/// Audio room: take/leave a seat (the engine grants publish; the SDK waits for
/// the grant before turning the mic on) and watch connection + roster state.
class AudioRoomPage extends StatefulWidget {
const AudioRoomPage({super.key, required this.client, required this.session});
final UTDStreamClient client;
final UTDAudioRoomSession session;
@override
State<AudioRoomPage> createState() => _AudioRoomPageState();
}
class _AudioRoomPageState extends State<AudioRoomPage> {
UTDAudioRoomSession get s => widget.session;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Audio room')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// STEP 3: render headless state straight from the listenables.
_Listen(s.connected, (c) => _row('connected', c)),
_Listen(
s.canPublish,
(c) => _row('canPublish (server-granted)', c),
),
_Listen(s.micEnabled, (m) => _row('mic on', m)),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: [
FilledButton(
onPressed: () => _try(() => s.takeSeat(1)),
child: const Text('Take seat 1'),
),
OutlinedButton(
onPressed: () => _try(() => s.leaveSeat()),
child: const Text('Leave seat'),
),
_Listen(
s.micEnabled,
(on) => TextButton(
onPressed: () => _try(() => s.setMicrophone(!on)),
child: Text(on ? 'Mute' : 'Unmute'),
),
),
],
),
const SizedBox(height: 12),
const Text('Remote participants:'),
Expanded(
child: ValueListenableBuilder<List<RemoteParticipant>>(
valueListenable: s.remoteParticipants,
builder: (_, list, _) => ListView(
children: [
for (final p in list)
ListTile(dense: true, title: Text(p.identity)),
],
),
),
),
],
),
),
);
}
Future<void> _try(Future<void> Function() f) async {
try {
await f();
} on UTDStreamException catch (e) {
if (mounted)
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.message)));
}
}
Widget _row(String label, bool on) => Row(
children: [
Icon(
on ? Icons.check_circle : Icons.circle_outlined,
size: 18,
color: on ? Colors.green : Colors.grey,
),
const SizedBox(width: 8),
Text(label),
],
);
@override
void dispose() {
// STEP 4: dispose the session (and the client when done with it).
s.dispose();
widget.client.dispose();
super.dispose();
}
}
/// Live stream: the host publishes camera+mic via goLive(); viewers subscribe.
class LiveStreamPage extends StatefulWidget {
const LiveStreamPage({
super.key,
required this.client,
required this.session,
});
final UTDStreamClient client;
final UTDLiveStreamSession session;
@override
State<LiveStreamPage> createState() => _LiveStreamPageState();
}
class _LiveStreamPageState extends State<LiveStreamPage> {
UTDLiveStreamSession get s => widget.session;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Live stream')),
body: Column(
children: [
Expanded(child: _LocalVideo(session: s)),
Padding(
padding: const EdgeInsets.all(12),
child: Wrap(
spacing: 8,
children: [
FilledButton(
onPressed: () => s.goLive(),
child: const Text('Go live'),
),
OutlinedButton(
onPressed: () => s.leave(),
child: const Text('Leave'),
),
],
),
),
],
),
);
}
@override
void dispose() {
s.dispose();
widget.client.dispose();
super.dispose();
}
}
/// 1:1 call: start the call, show its status, and hang up.
class CallPage extends StatefulWidget {
const CallPage({
super.key,
required this.client,
required this.session,
required this.callee,
});
final UTDStreamClient client;
final UTDCallSession session;
final String callee;
@override
State<CallPage> createState() => _CallPageState();
}
class _CallPageState extends State<CallPage> {
UTDCallSession get s => widget.session;
@override
void initState() {
super.initState();
// STEP 5: caller starts the call; mic (and camera for video) auto-publish.
s.start(calleeIdentity: widget.callee);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('1:1 call')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ValueListenableBuilder<UTDCallStatus?>(
valueListenable: s.status,
builder: (_, st, _) => Text(
'Status: ${st?.name ?? '—'}',
style: Theme.of(context).textTheme.titleLarge,
),
),
const SizedBox(height: 24),
FilledButton.tonalIcon(
icon: const Icon(Icons.call_end),
label: const Text('Hang up'),
onPressed: () async {
await s.end();
if (context.mounted) Navigator.of(context).pop();
},
),
],
),
),
);
}
@override
void dispose() {
s.dispose();
widget.client.dispose();
super.dispose();
}
}
/// Renders the local camera track once it is published, else a placeholder.
class _LocalVideo extends StatelessWidget {
const _LocalVideo({required this.session});
final UTDLiveStreamSession session;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: session.cameraEnabled,
builder: (_, on, _) {
final lp = session.localParticipant;
VideoTrack? track;
if (on && lp != null) {
for (final pub in lp.videoTrackPublications) {
final t = pub.track;
if (t is VideoTrack) {
track = t;
break;
}
}
}
if (track == null) {
return const ColoredBox(
color: Colors.black12,
child: Center(child: Text('Camera off — tap "Go live"')),
);
}
return VideoTrackRenderer(track);
},
);
}
}
/// Small helper that renders a bool [ValueListenable].
class _Listen extends StatelessWidget {
const _Listen(this.listenable, this.builder);
final ValueListenable<bool> listenable;
final Widget Function(bool) builder;
@override
Widget build(BuildContext context) => ValueListenableBuilder<bool>(
valueListenable: listenable,
builder: (_, v, _) => builder(v),
);
}