Client SDKs/Flutter
Flutter
velo_client is the join half of Velo and nothing else. A future gets you into a room, a sealed event stream carries the call, and there is no project-key client anywhere in the package, because an app binary is not a secret.
The package
velo_client is added by git reference to the Velo repository, not from pub.dev. It is a thin wrapper rather than a fork: media handling stays in livekit_client, which is a regular dependency resolved for you, so upgrading the media stack is a version bump on your side. The package re-exports the livekit_client types you need, so Room, Track, ConnectionState, VideoTrackRenderer and the participant classes all come from velo_client.
- Dart SDK
^3.11.0and Flutter3.35.0or newer. livekit_client^2.10.0, which in turn sets the platform floor: AndroidminSdkVersion21 or newer and an iOS deployment target of 13.0 or newer, both fromflutter_webrtc.- A physical device to publish camera. The iOS Simulator has none, and
setCameraEnabled(true)fails there.
There is no VeloClient, no apiKey parameter and no admin surface here, on purpose. A Velo project API key starts with vk_, and anyone can unzip an APK or an IPA and read the strings out of it, so a key shipped in an app is a published key. A client gets into a room exactly two ways: your backend mints a room token with POST /v1/tokens and sends it to the app, or the app exchanges a room code with POST /v1/codes/{code}/exchange. As a backstop, VeloRoom.connect throws an ArgumentError on anything starting with vk_. Never proxy the project key itself.
Install
The package is not published to pub.dev, so the dependency is a git reference with a path into the repository.
dependencies:
velo_client:
git:
url: https://github.com/judeotine/Velo.git
path: sdks/flutterflutter pub getPin it with ref when you want a fixed commit or tag rather than the default branch. Nothing else is needed: livekit_client and http come with it, and the native side is whatever flutter_webrtc already installs.
Permissions
Declare these in the consuming app, not in the package. You will hit them on the first call, not at connect.
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<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.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />android/app/src/main/AndroidManifest.xml, ios/Runner/Info.plist and ios/Podfile.
CAMERAandRECORD_AUDIOare runtime permissions on Android 6.0 and newer. Request them before callingsetCameraEnabledorsetMicrophoneEnabled, for example withpermission_handler. Bluetooth headset routing needsPermission.bluetoothConnectgranted at runtime too.- Set
minSdkVersion 21or higher inandroid/app/build.gradle. - The
audiobackground mode keeps a call running when the app is backgrounded on iOS.
Getting a token
An app needs two strings to join: a room token and the url that came with it. A room code is the path that puts nothing secret on the device at all. It is a short string bound to one room and one role, created once on your server, redeemed by the app against the public, unauthenticated exchange endpoint, so it is safe to do straight from the app. If the code was minted with a fixed identity, omit identity and the server uses the pinned one.
import 'package:velo_client/velo_client.dart';
final VeloToken token = await VeloRoomCodes.exchange(
baseUrl: 'https://api.usevelo.xyz',
code: 'abcdefghjkmnpqrs',
identity: 'patient-1187',
);
final VeloRoom room = await VeloRoom.connect(
token: token.token,
url: token.url,
);Either way, only token and url reach the media server.
| Field | Type | Required | Description |
|---|---|---|---|
| baseUrl | String | Yes | The API origin, https://api.usevelo.xyz. Trailing slashes are stripped. |
| code | String | Yes | The room code. It is url encoded into POST /v1/codes/{code}/exchange. |
| identity | String? | No | Who the resulting token admits. Ignored when the code was created with a pinned identity, required otherwise. |
| httpClient | http.Client? | No | Your own client, for a shared connection pool or a test. One is created and closed for you when this is null. |
| timeout | Duration | No | Defaults to 30 seconds. Exceeding it throws VeloTimeoutException. A zero or negative duration disables the timeout. |
| headers | Map<String, String>? | No | Extra headers on the exchange request. |
It returns a VeloToken.
- token
- The room token to dial with.
- url
- The Velo media server websocket url.
- expiresAt
- A UTC
DateTime, parsed fromexpires_atand falling back to the token claim when the field will not parse. A response with neither is rejected. - role
- Nullable. The template role the code carries.
- roomName
- Nullable, and not a server field: read from the token claim
video.roomwith theprj_namespace prefix stripped. - identity
- Nullable. Read from the token claims the same way. This is how you learn the identity an identity-locked code pinned for you.
- isExpired
- Takes an optional now, for deciding whether to re-exchange before dialling.
The claims are read with LiveKitJwtPayload from livekit_client, and nothing is verified by that read. They are nullable because a token this SDK cannot parse stays perfectly usable, just opaque. Unlike the other SDKs, VeloToken here carries no publishProfile: the field is on the wire but this package does not surface it.
Connecting
VeloRoom.connect is a static method that returns a connected VeloRoom, not a constructor you dial afterwards. It builds the room, attaches the event listener and joins. roomOptions defaults to const RoomOptions() and connectOptions is nullable, and both are livekit_client types.
final VeloRoom room = await VeloRoom.connect(
token: token.token,
url: token.url,
roomOptions: const RoomOptions(adaptiveStream: true, dynacast: true),
);- A failed handshake disposes the wrapper for you and rethrows, so a failed join leaves no listener and no half-built room behind.
- The guard runs before any of that. A blank token, a blank url, or a
vk_key throwsArgumentError, because that is a programming mistake rather than a runtime condition.assertRoomTokenis exported if you want the same check somewhere else. disposeis idempotent and closes the event stream, the listener and the room. Call it when the screen goes away, afterdisconnect.
ArgumentError: VeloRoom.connect was given a Velo project API key instead of a room
token. Project keys must never ship inside an app binary: mint a room token on your own
backend and send that to the app, or use VeloRoomCodes.exchange.Call controls
The call arrives as one broadcast stream of VeloRoomEvent. It is a sealed class, so an exhaustive switch needs no default branch and a new event type becomes a compile error rather than a silent miss.
final StreamSubscription<VeloRoomEvent> sub = room.events.listen((event) {
switch (event) {
case VeloConnectionStateChanged(:final ConnectionState state):
setState(() => _state = state);
case VeloParticipantJoined(:final RemoteParticipant participant):
debugPrint('${participant.identity} joined');
case VeloParticipantLeft(:final RemoteParticipant participant):
debugPrint('${participant.identity} left');
}
});- VeloConnectionStateChanged
- Carries
state, andreasonwhen the room disconnected. Reconnecting and reconnected both arrive here, so one case covers the whole lifecycle. - VeloParticipantJoined
- Carries the
RemoteParticipantthat arrived. - VeloParticipantLeft
- Carries the
RemoteParticipantthat left.
The controls and the current state are plain members. setMicrophoneEnabled and setCameraEnabled throw a VeloException with code not_connected if there is no local participant yet, rather than silently doing nothing.
await room.setMicrophoneEnabled(true);
await room.setCameraEnabled(true);
for (final RemoteParticipant participant in room.remoteParticipants) {
debugPrint(participant.identity);
}
await room.disconnect();
await sub.cancel();
await room.dispose();connectionState,localParticipant,remoteParticipantsandnameread straight off the live room.room.roomis the underlyinglivekit_clientroom, for screen share, data channels and anything else this wrapper does not expose.- To render video, pass a track from
localParticipantorremoteParticipantstoVideoTrackRenderer. This package ships no UI.
Chat and reactions
VeloChat is built from a room that is already connected, and it never owns that room. It rides the data channel the room already has, under the topic velo.chat, so there is no extra endpoint and no server round trip. Messages and reactions share one bounded in-memory history, oldest first, historyLimit entries deep and 200 by default, deduplicated by payload id. Sending takes an optional target identity, and with one set the payload is published only to that participant and arrives marked direct. A reaction body is a short name your application chooses, such as clap or raise_hand; the SDK defines no vocabulary and ships no emoji.
final VeloChat chat = VeloChat(room: room, historyLimit: 200);
final StreamSubscription<VeloChatRecord> sub = chat.events.listen((record) {
switch (record.kind) {
case VeloChatKind.reaction:
flash(record.senderIdentity, record.body);
case VeloChatKind.message:
setState(() => _history = chat.records);
}
});
await chat.sendMessage('The nurse will join in a moment');
await chat.sendReaction('raise_hand');
await chat.sendMessage('Your results are ready', to: 'patient-1187');
await sub.cancel();
await chat.dispose();records is the history as an unmodifiable list, oldest first, which a list widget rebuilds from. events is a broadcast stream of every record as it lands, local ones included, for the things you do once rather than render. clear() empties the history without detaching, and dispose() detaches for good: it is a no-op the second time, and sending afterwards raises a VeloException with code chat_closed. A body that is empty or over the limit raises one with code invalid_chat_body and field body, before anything is published.
Each VeloChatRecord carries id, kind, body, sentAt, senderIdentity, senderName, local and direct. The sender identity is taken from the participant on the data event rather than from the payload, so a peer cannot claim to be someone else. What you send is echoed into your own history immediately, so a widget renders it without a round trip. A message body is 1 to 2000 characters after trimming and a reaction 1 to 64, rejected locally before publishing; a malformed or unknown payload from a remote peer is dropped silently rather than raised, because a peer you do not control decides what arrives.
Errors and disconnects
Every failure from the exchange is a VeloException carrying the code and message from Velo's envelope, plus status, field and the parsed body. Programmer errors, a blank baseUrl or a project key passed as a token, throw ArgumentError instead, because those are bugs rather than conditions.
try {
final VeloToken token = await VeloRoomCodes.exchange(
baseUrl: 'https://api.usevelo.xyz',
code: code,
identity: identity,
);
return await VeloRoom.connect(token: token.token, url: token.url);
} on VeloQuotaException catch (error) {
showUpgradePrompt(error.message);
} on VeloPermissionException catch (error) {
disableControl(error.permission);
} on VeloTimeoutException {
showMessage('the network is too slow right now');
} on VeloConnectionException {
showOffline();
} on VeloException catch (error) {
report(error.status, error.code, error.message);
}VeloQuotaExceptionis any 402 andVeloPermissionExceptiona 403 whose code ispermission_denied, withpermissionnaming the missing grant. Both extendVeloException, so order youronclauses narrowest first. There is no rate-limit subtype: a 429 arrives asVeloExceptionwith coderate_limited.VeloConnectionExceptionmeans the request never reached Velo, with codenetwork_error.VeloTimeoutExceptionmeans it exceededtimeout, with codetimeout. Both carry the original throw ascause.- An unknown, expired, disabled or exhausted room code all come back the same way, a 404 with code
code_not_foundand the messagecode not found, expired, disabled or fully used, so a caller learns nothing about the code space. - When the body is not a Velo envelope the code falls back to
http_{status}and the message to the first 512 characters of the body. A body that is not JSON, or is JSON but not an object, isinvalid_response.
A drop after a successful join is not an exception to catch. It arrives on the event stream as a VeloConnectionStateChanged with state reconnecting, and again when the room comes back or ends with a reason. The media stack retries on its own; show a banner and leave it alone. A room token expires, so a session longer than its TTL needs a fresh token and a fresh connect.
Where to go next
- Your first room walks the four requests that get a call running, from the server side you have to build anyway.
- The REST API is where room creation, token minting and room codes live. Every one of them belongs on your backend.
- Templates and roles explains what the
roleon a token actually fixes: publish sources, the subscribe graph, permissions, priority and capacity. - Authentication is the full account of the two credentials, and why only one of them may ever reach a device.
Was this page useful?