Skip to content

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.0 and Flutter 3.35.0 or newer.
  • livekit_client ^2.10.0, which in turn sets the platform floor: Android minSdkVersion 21 or newer and an iOS deployment target of 13.0 or newer, both from flutter_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.

yamlpubspec.yaml
dependencies:
  velo_client:
    git:
      url: https://github.com/judeotine/Velo.git
      path: sdks/flutter
bash
flutter pub get

Pin 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.

xml
<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.

  • CAMERA and RECORD_AUDIO are runtime permissions on Android 6.0 and newer. Request them before calling setCameraEnabled or setMicrophoneEnabled, for example with permission_handler. Bluetooth headset routing needs Permission.bluetoothConnect granted at runtime too.
  • Set minSdkVersion 21 or higher in android/app/build.gradle.
  • The audio background 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.

dart
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.

VeloRoomCodes.exchange parameters
FieldTypeRequiredDescription
baseUrlStringYesThe API origin, https://api.usevelo.xyz. Trailing slashes are stripped.
codeStringYesThe room code. It is url encoded into POST /v1/codes/{code}/exchange.
identityString?NoWho the resulting token admits. Ignored when the code was created with a pinned identity, required otherwise.
httpClienthttp.Client?NoYour own client, for a shared connection pool or a test. One is created and closed for you when this is null.
timeoutDurationNoDefaults to 30 seconds. Exceeding it throws VeloTimeoutException. A zero or negative duration disables the timeout.
headersMap<String, String>?NoExtra 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 from expires_at and 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.room with the prj_ 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.

dart
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 throws ArgumentError, because that is a programming mistake rather than a runtime condition. assertRoomToken is exported if you want the same check somewhere else.
  • dispose is idempotent and closes the event stream, the listener and the room. Call it when the screen goes away, after disconnect.
texta project key passed as a token
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.

dart
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, and reason when the room disconnected. Reconnecting and reconnected both arrive here, so one case covers the whole lifecycle.
VeloParticipantJoined
Carries the RemoteParticipant that arrived.
VeloParticipantLeft
Carries the RemoteParticipant that 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.

dart
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, remoteParticipants and name read straight off the live room.
  • room.room is the underlying livekit_client room, for screen share, data channels and anything else this wrapper does not expose.
  • To render video, pass a track from localParticipant or remoteParticipants to VideoTrackRenderer. 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.

dart
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.

dart
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);
}
  • VeloQuotaException is any 402 and VeloPermissionException a 403 whose code is permission_denied, with permission naming the missing grant. Both extend VeloException, so order your on clauses narrowest first. There is no rate-limit subtype: a 429 arrives as VeloException with code rate_limited.
  • VeloConnectionException means the request never reached Velo, with code network_error. VeloTimeoutException means it exceeded timeout, with code timeout. Both carry the original throw as cause.
  • An unknown, expired, disabled or exhausted room code all come back the same way, a 404 with code code_not_found and the message code 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, is invalid_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 role on 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?

Edit this page on GitHub