05 โ Flutter app integration
Your Flutter app is the call client. It gets a token from your backend (see
Node / Laravel) and connects to
arzbridge media with the official livekit_client SDK.
The app never holds your API key/secret โ only the short-lived token your backend returns.
1. Add dependencies
# pubspec.yaml
dependencies:
livekit_client: ^2.8.1 # latest stable
permission_handler: ^12.0.0
http: ^1.2.0 # to call your backend for a token
flutter pub get
Requires Flutter 3.3.0+ (recommended). Tested against
livekit_client2.8.x.
2. Platform setup (required for camera/mic)
iOS โ ios/Runner/Info.plist
<key>NSCameraUsageDescription</key>
<string>$(PRODUCT_NAME) needs the camera for video calls</string>
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) needs the microphone for calls</string>
<!-- keep audio alive when the app is backgrounded during a call -->
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>voip</string>
</array>
Set the iOS deployment target to 12.1+ (ios/Podfile: platform :ios, '12.1').
Android โ android/app/src/main/AndroidManifest.xml
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.microphone" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
Set minSdkVersion 21 (or higher) in android/app/build.gradle.
3. Request runtime permissions
import 'package:permission_handler/permission_handler.dart';
Future<bool> ensureCallPermissions() async {
final statuses = await [Permission.camera, Permission.microphone].request();
return statuses.values.every((s) => s.isGranted);
}
4. Get a token from YOUR backend
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<({String token, String wsUrl})> fetchCallToken(String roomId) async {
final res = await http.post(
Uri.parse('https://your-backend.example.com/api/calls/join'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer <YOUR_APP_SESSION_TOKEN>', // your own auth
},
body: jsonEncode({'room_id': roomId}),
);
if (res.statusCode != 200) {
throw Exception('Failed to get call token: ${res.body}');
}
final data = jsonDecode(res.body);
return (token: data['token'] as String, wsUrl: data['wsUrl'] as String);
}
5. A complete call screen
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
class CallScreen extends StatefulWidget {
final String roomId;
const CallScreen({super.key, required this.roomId});
@override
State<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen> {
Room? _room;
EventsListener<RoomEvent>? _listener;
bool _micOn = true, _camOn = true;
CameraPosition _cameraPos = CameraPosition.front;
@override
void initState() {
super.initState();
_connect();
}
Future<void> _connect() async {
if (!await ensureCallPermissions()) return;
final creds = await fetchCallToken(widget.roomId);
final room = Room(
roomOptions: const RoomOptions(adaptiveStream: true, dynacast: true),
);
final listener = room.createListener();
listener
..on<TrackSubscribedEvent>((_) => setState(() {}))
..on<TrackUnsubscribedEvent>((_) => setState(() {}))
..on<ParticipantConnectedEvent>((_) => setState(() {}))
..on<ParticipantDisconnectedEvent>((_) => setState(() {}))
..on<RoomDisconnectedEvent>((e) {
if (mounted) Navigator.of(context).maybePop();
});
await room.connect(creds.wsUrl, creds.token);
await room.localParticipant?.setMicrophoneEnabled(true);
await room.localParticipant?.setCameraEnabled(true);
setState(() { _room = room; _listener = listener; });
}
Future<void> _toggleMic() async {
_micOn = !_micOn;
await _room?.localParticipant?.setMicrophoneEnabled(_micOn);
setState(() {});
}
Future<void> _toggleCam() async {
_camOn = !_camOn;
await _room?.localParticipant?.setCameraEnabled(_camOn);
setState(() {});
}
Future<void> _flipCamera() async {
final pubs = _room?.localParticipant?.videoTrackPublications ?? [];
final track = pubs.isNotEmpty ? pubs.first.track : null;
if (track is LocalVideoTrack) {
_cameraPos = _cameraPos == CameraPosition.front
? CameraPosition.back
: CameraPosition.front;
await track.setCameraPosition(_cameraPos);
}
}
@override
void dispose() {
_listener?.dispose();
_room?.dispose(); // disconnects + releases camera/mic
super.dispose();
}
@override
Widget build(BuildContext context) {
final room = _room;
if (room == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
// Collect all video tracks (local + remote) to render.
final tracks = <VideoTrack>[];
final localPubs = room.localParticipant?.videoTrackPublications ?? [];
final localCam = localPubs.isNotEmpty ? localPubs.first.track : null;
if (localCam is VideoTrack) tracks.add(localCam);
for (final p in room.remoteParticipants.values) {
for (final pub in p.videoTrackPublications) {
final t = pub.track;
if (t is VideoTrack) tracks.add(t);
}
}
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: GridView.count(
crossAxisCount: tracks.length <= 1 ? 1 : 2,
children: [
for (final t in tracks)
Container(
margin: const EdgeInsets.all(2),
color: Colors.grey[900],
child: VideoTrackRenderer(t),
),
],
),
),
bottomNavigationBar: BottomAppBar(
color: Colors.black87,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(icon: Icon(_micOn ? Icons.mic : Icons.mic_off, color: Colors.white), onPressed: _toggleMic),
IconButton(icon: Icon(_camOn ? Icons.videocam : Icons.videocam_off, color: Colors.white), onPressed: _toggleCam),
IconButton(icon: const Icon(Icons.cameraswitch, color: Colors.white), onPressed: _flipCamera),
IconButton(icon: const Icon(Icons.call_end, color: Colors.red), onPressed: () => _room?.disconnect()),
],
),
),
);
}
}
6. Data messages (optional)
Send and receive arbitrary messages over the same connection (needs
import 'dart:convert';):
// send
await room.localParticipant?.publishData(
utf8.encode(jsonEncode({'type': 'reaction', 'emoji': '๐'})),
reliable: true, topic: 'signals',
);
// receive
listener.on<DataReceivedEvent>((e) {
final msg = jsonDecode(utf8.decode(e.data));
// handle msg, e.topic, e.participant
});
7. Lifecycle tips
- Always
dispose()the listener and room when leaving the screen (releases the camera/mic and disconnects). RoomOptions(adaptiveStream: true, dynacast: true)saves bandwidth/CPU.- The SDK auto-reconnects on network blips; listen to
RoomDisconnectedEventfor a permanent drop. - For audio-only calls, just skip
setCameraEnabled(true).
8. Troubleshooting
| Symptom | Fix |
|---|---|
| Black video / no camera | Permissions not granted; check Info.plist/manifest + runtime request |
| Connects then immediately drops | Token expired or wrong ws_url; re-fetch from your backend |
| No remote video | The other side hasn't published, or can_subscribe:false on the token |
| Works on Wi-Fi, not cellular | Network blocks UDP โ the SDK falls back to TCP automatically; ensure latest SDK |
| iOS build fails | Set deployment target โฅ 12.1; run pod install |
Reference: LiveKit Flutter SDK docs. The SDK is fully compatible โ arzbridge runs standard LiveKit.