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_client 2.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 RoomDisconnectedEvent for 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.