The Flutter SDK is for iOS and Android apps built with Flutter. It adds calls, chat, and live streaming. It has the same ideas as the web and React Native SDKs, written the Dart way.
It runs on the user's phone and holds only a token, never your API
key. Your backend makes the token with the TypeScript,
Python, Go, Java or PHP SDK. Your app fetches it from your own endpoint (for
example POST /join-room, using the http package) and passes it in.
Why a backend, and how to write it: the API key can't live in an app, so one small route on your server makes the token. Build your token endpoint has the full route in TypeScript, Python, Go, Java, PHP and Dart, plus the plain HTTP call for any other language. You don't need Node.js unless your backend already uses it.
The shortest version, in your Flutter app:
final livqeno = Livqeno(
token: token,
endpoint: endpoint,
iceServers: iceServers, // from the grant: List<LivqenoIceServer>, see the Quickstart
);
final room = await livqeno.join('room_123');
await room.enableCamera();
await room.enableMicrophone();Always pass iceServers from the grant. The Quickstart
Flutter tab shows how to read it with LivqenoIceServer.fromJson. Without it
there's no relay (TURN), and calls on mobile data often fail.
There are three packages. Install only what you need:
| Package | For |
|---|---|
livqeno_rtc | Video and audio calls |
livqeno_chat | Chat |
livqeno_live | Live streaming (uses the other two) |
A video app doesn't pull in chat code, and a chat app doesn't pull in WebRTC.
Install
dependencies:
livqeno_rtc: ^0.2.2
livqeno_chat: ^0.1.1 # only if you want messagingOr run flutter pub add livqeno_rtc livqeno_chat.
Add permissions
Phones won't let an app use the camera or mic unless the app declares it up front. These lines go in your native project files, which the SDK can't edit for you.
iOS: ios/Runner/Info.plist
<key>NSCameraUsageDescription</key>
<string>Camera access is used for video calls.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Microphone access is used for calls.</string>Android: android/app/src/main/AndroidManifest.xml
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />Set minimum versions
- Android:
minSdkVersion 23inandroid/app/build.gradle. - iOS: deployment target 13.0 or later.
WebRTC won't build below these.
Idiomatic Dart, identical concepts
The ideas are the same as the JavaScript SDKs (@livqeno/rtc, @livqeno/chat). Only the style is Dart. This means
docs written for the web (like Rooms & participants)
still apply; you just read events from streams instead of callbacks:
LivqenoRoomandLivqenoChatareChangeNotifiers, so they work withListenableBuilder.- Events come as typed streams, instead of
room.on('event', handler).
// In the widget tree
ListenableBuilder(listenable: room, builder: (context, _) => ...)
// Outside it
room.connectionStateChanges.listen((state) => ...);
room.participantChanges.listen((participants) => ...);
chat.messages.listen((message) => ...);Camera, microphone, screen share
await room.enableCamera();
await room.disableCamera();
await room.enableMicrophone();
await room.disableMicrophone();
await room.switchCamera(); // front <-> rear
await room.enableScreenShare();switchCamera()flips between front and rear cameras. (The web has no equivalent.)- Screen sharing works out of the box on Android.
- On iOS, screen sharing needs a Broadcast Upload Extension. You add
it as a target in Xcode; no package can do it for you. Without it,
enableScreenShare()throws.
Show video
LivqenoVideoView(
participant: participant,
room: room, // enables automatic updates
kind: LivqenoTrackKind.camera,
fit: LivqenoVideoFit.cover,
placeholder: const ColoredBox(color: Colors.black),
)Pass room so the widget updates itself when tracks change. It also cleans up
its native video texture when the widget goes away, so a scrolling grid
doesn't leak memory.
Pass visible: false for a tile that's scrolled away or covered, and the
server stops sending that video until it's visible again. A zero-size or
disposed view counts as hidden too, and a person shown in two views stays
on while either is visible.
LivqenoVideoView(participant: p, room: room, visible: isOnScreen)
room.setVideoEnabled(p, LivqenoTrackKind.camera, false); // pause by hand; wins over everything
Livqeno(..., autoSubscribe: false); // start remote video pausedSpeaking and connection quality
room.activeSpeakers; // loudest first, you included
room.activeSpeakerChanges.listen((speakers) { });
participant.isSpeaking; // and participant.audioLevel, 0..1
participant.connectionQuality; // LivqenoConnectionQuality.good, …
room.connectionQualityChanges.listen((update) { });
room.subscriptionStateChanges.listen((update) { }); // a video paused or resumedYour camera only sends the qualities somebody is watching (dynacast, on
by default).
Permissions
final granted = await LivqenoPermissions.request();
// { LivqenoPermission.camera: true, LivqenoPermission.microphone: false }
await LivqenoPermissions.require(); // throws LivqenoPermissionException| Method | What it does |
|---|---|
request() | Shows the OS prompt and returns what was granted. |
require() | Same, but throws LivqenoPermissionException if refused. |
Ask for permission at a moment that makes sense to the user, for example when they tap Join call, not when the app first opens. People are more likely to say yes when they know why.
This package can't check permission status without prompting. If you need
that, or need to know "denied" vs "permanently denied", add
permission_handler:
final status = await Permission.camera.status;
if (status.isPermanentlyDenied) {
await openAppSettings();
} else {
await LivqenoPermissions.require([LivqenoPermission.camera]);
}Chat
Chat uses its own chat token, separate from the call token. Your backend makes it (and adds the user to the conversation first). In your Flutter app:
final chat = LivqenoChat(token: chatToken, apiUrl: apiUrl);
await chat.connect('room_123');
chat.messages.listen((message) => print('${message.senderId}: ${message.text}'));
await chat.send('Hello everyone!');More things you can do:
final page = await chat.history(limit: 50);
await chat.edit(messageId, 'Updated');
await chat.delete(messageId);
await chat.addReaction(messageId, '👍');
await chat.markAsRead(messageId);
await chat.startTyping();
final thread = await chat.thread(messageId);
final present = await chat.getPresence();Streams you can listen to: messages, messageUpdates,
messageDeletions, typing, presence, reactions, readReceipts,
connectionStateChanges, errors.
send() finishes only after the message is safely saved. It adds an
idempotency key for you, so retries don't create duplicates. See
Chat Overview.
Reconnection
Phones drop connection often: a lift, a tunnel, leaving Wi-Fi. Reconnecting
is done for you. LivqenoChat retries with backoff and jitter, re-joins its
rooms, and stops after a maximum number of tries.
A rejected token is never retried. Close codes 4401 and 4403 are final. Retrying would just fail again, so instead ask your backend for a new token and connect again.
Live streaming
Use this for broadcasts, like a creator going live from their phone. Your
backend decides each person's role first: addHost() for a host,
createViewerToken() for a viewer.
Add livqeno_live. It uses livqeno_rtc and livqeno_chat under the hood, so a
stream's room and chat are a normal LivqenoRoom and LivqenoChat.
dependencies:
livqeno_live: ^0.2.0Or run flutter pub add livqeno_live.
import 'package:livqeno_live/livqeno_live.dart';
final stream = await LivqenoLiveStream.join(credentials);
if (stream.isHost) {
await stream.room.enableCamera();
await stream.room.enableMicrophone();
}
stream.room.participantChanges.listen((participants) => ...);
stream.chat?.messages.listen((message) => ...);
await stream.react('❤️');
await stream.leave();Large audiences (HLS playback)
If a stream is created with edgeScaling, or more people try to watch than
the RTC room can hold, Livqeno moves those viewers to HLS instead — a video
format phones and browsers can play without joining the call's room at all.
For an HLS viewer, stream.delivery is 'hls' (instead of 'rtc'), and
calling stream.room raises an error. Play the stream with
LivqenoLivePlayer instead:
if (stream.delivery == 'hls') {
LivqenoLivePlayer(
url: stream.playbackUrl!,
lowLatencyUrl: stream.lowLatencyPlaybackUrl, // LL-HLS, tried first
beacon: stream.playbackBeacon, // watch-time heartbeat for viewer-minute billing
)
} else {
// normal RTC viewer: stream.room
}Mint these credentials on your server with
createViewerToken(streamId, identity, acceptPlayback: true) — see
TypeScript → Live streaming.
LivqenoLivePlayer keeps retrying every retryEvery (2 seconds by default)
until the stream actually goes live, and shows placeholder until the first
frame arrives.
The stage
stream.stageEvents tells you when someone is invited onto the stage,
promoted to co-host, or demoted back to viewer — these travel as special
messages through the stream's chat. Only your server can send invited,
promoted, or demoted (an app can't fake one of these), but a viewer's own
app can send raise_hand and lower_hand:
stream.stageEvents.listen((event) {
if (event.action == 'promoted' && event.forMe) {
// The server already minted host credentials for you; rejoin with them.
}
});Each LivqenoLiveStageEvent has four fields: action, identity, forMe
(true when the event is about you), and role (only set on promoted).
Production
- iOS screen sharing needs a Broadcast Upload Extension target in Xcode. There's no written guide yet. Contact support if you get stuck.
minSdkVersion 23and iOS 13+ are hard minimums. WebRTC won't build below them.
Next steps
- Build the backend endpoint and a working call: Quickstart (Flutter tab).
- Set up camera and mic permissions in depth: Permissions.
- Learn the call API (same ideas as Dart): Rooms & participants.
- Add chat: Chat quickstart.