qriib_meet
Flutter SDK from Qriib for Qriib room management and embedded audio/video meetings on Android and iOS. It includes a pre-join preview, customizable meeting UI, audio routing, camera switching, and screen sharing. No public chat API is exported. Desktop and web are not supported host platforms, even where internal media code or device-capability types mention them.
Version and installation
These examples describe version 0.3.2. The published 0.2.1 archive has a
different API: it includes V4 recording retrieval methods (getRecord,
getRecordingLink, getRecords), but not the V5 recording start/stop or
pre-join APIs described here. Its join API also differs. Check the API for the
version you install.
Add the package to your Flutter application, create a single client from trusted credentials. The SDK owns request signing and the embedded meeting experience; your application owns its navigation, identity, and secure credential delivery.
Requirements: Dart >=3.6.0 <4.0.0 and Flutter >=3.29.0. The plugin declares Android minSdk 21 and iOS 13.0; dependencies and your host's toolchain can impose additional requirements. Use a physical device to validate media and routing.
For version 0.3.2, add:
dependencies:
qriib_meet: ^0.3.2
If 0.3.2 is not yet published, use an approved local or source dependency until it is available. Consult the API reference for the version you install.
Project secrets embedded in a distributed app can be extracted. Dart-defines and ignored local files do not make them confidential. Prefer room management on a trusted backend and fresh links for client-side joining. The credential-based example below is for integrations that explicitly accept that security boundary.
The identifiers and room settings below are application-owned values. Obtain them from authenticated application state or your backend; do not copy user data, credentials, or environment-specific values into source code.
Use the built-in newClientRoomId() for every create-room request. It creates
a cryptographically random UUID-style string and contains no user data,
credentials, or room metadata.
final qriib = QriibMeetClient.withProjectCredentials(
apiKey: const String.fromEnvironment('QRIIB_API_KEY'),
secretKey: const String.fromEnvironment('QRIIB_SECRET_KEY'),
enableNetworkLogging: false,
);
final response = await qriib.rooms.createQuickVideoRoom(
projectId: projectId,
moderatorId: moderatorId,
name: participantName,
maxParticipants: roomCapacity,
emptyTimeout: emptyRoomTimeoutSeconds,
clientRoomId: newClientRoomId(),
metadata: QriibRoomMetadata(
roomTitle: roomTitle,
welcomeMessage: welcomeMessage,
),
);
final finalLink = finalLinkFromResponse(response);
if (finalLink == null) {
throw const QriibMeetException('The response has no final_link to open.');
}
if (!context.mounted) return;
await qriib.meetings.join(
context: context,
finalLink: finalLink,
configuration: meetingConfiguration,
);
Use the built-in finalLinkFromResponse to read the fresh final_link out of
any create, join, or start response. It accepts both the
response['data']['final_link'] envelope and a top-level final_link, and
returns null when no usable link is present. If data is a map, that envelope
takes precedence; the helper does not fall back to the outer link when the
nested link is absent.
Run flutter pub get, then import only the public entry point:
import 'package:flutter/material.dart';
import 'package:qriib_meet/qriib_meet.dart';
Choose where credentials live
Recommended for distributed apps: your authenticated backend owns the
project signing secret, calls the Qriib room API, and returns a fresh
final_link to the intended participant. The Flutter app can join it with
QriibMeetings() without project credentials. Built-in recording controls are
not available in that credential-free flow.
A fuller walkthrough of the layout, every flag, and the widget-test keys is in doc/meeting-ui.md.
The client also supports direct signed room requests, useful for trusted
integrations and development. Treat that as an explicit credential-exposure
choice. --dart-define, environment files, obfuscation, and CI secret injection
do not protect a secret compiled into a distributed app. The SDK's credential
objects retain values for signing; they are not a secret store.
Never commit, persist unnecessarily, log, or send credentials, final_link,
connection tokens, or tool tokens to analytics. Examples below take sensitive
values as explicit inputs; none contains usable credentials.
Configure the host
Android
The plugin manifest merges INTERNET, CAMERA, RECORD_AUDIO, screen-capture
foreground-service permissions, and QriibScreenShareService into the host.
Ensure your merged manifest retains them. Explicit camera/microphone declarations
in android/app/src/main/AndroidManifest.xml may also be used:
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
No additional screen-share service declaration is normally needed. See screen sharing for Android consent ordering.
On iOS, screen sharing without a Broadcast Upload Extension captures only
this app's own screen (ReplayKit in-app recording). Sharing other apps or the
home screen requires a broadcast extension, which this release does not yet
wire up; see the iOS notes in doc/screen-sharing.md.
iOS
Add purpose strings to ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>Use the camera to share video in a meeting.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Use the microphone to share audio in a meeting.</string>
Enable the permission-handler macros in your existing Podfile post_install
hook. Merge this into the hook rather than adding a second one; retain existing
build settings and definitions:
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
definitions = config.build_settings['GCC_PREPROCESSOR_DEFINITIONS']
definitions = definitions.nil? ? ['$(inherited)'] : Array(definitions)
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] =
(definitions + ['PERMISSION_CAMERA=1', 'PERMISSION_MICROPHONE=1']).uniq
end
end
end
Then install/update the host's pods as usual. iOS screen sharing is in-app only through this public API; a Broadcast Upload Extension is not wired up.
Join a fresh link from your backend
This widget accepts your app's authenticated backend function. That function
must obtain a new link for this user, not return a cached invitation URL.
Place the widget under a MaterialApp/Navigator and Scaffold.
class JoinMeetingButton extends StatefulWidget {
const JoinMeetingButton({super.key, required this.fetchFreshFinalLink});
final Future<String> Function() fetchFreshFinalLink;
@override
State<JoinMeetingButton> createState() => _JoinMeetingButtonState();
}
class _JoinMeetingButtonState extends State<JoinMeetingButton> {
final _meetings = QriibMeetings();
bool _joining = false;
Future<void> _join() async {
if (_joining) return;
setState(() => _joining = true);
try {
final link = await widget.fetchFreshFinalLink();
if (!mounted) return;
await _meetings.join(
context: context,
finalLink: link,
configuration: const QriibMeetingConfiguration(
title: 'Team meeting',
preJoin: QriibPreJoinConfiguration(enabled: true),
),
);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Unable to join. Please try again.')),
);
} finally {
if (mounted) setState(() => _joining = false);
}
}
@override
Widget build(BuildContext context) => FilledButton(
onPressed: _joining ? null : _join,
child: Text(_joining ? 'Joining…' : 'Join meeting'),
);
}
join accepts context, finalLink, optional configuration, userName,
userImage, onDisconnected, and (unreleased) roomId. userName and
userImage are reserved: they currently do not set the participant's identity
or avatar. Establish identity through room creation/join data on the server.
roomId overrides the link's room ID for recording; it does not select a
different media room.
Important behavior:
- Both camera and microphone permissions are requested on every join, including audio-only rooms and joins with hidden camera controls.
- Without pre-join, camera and microphone start enabled. Pre-join starts a local preview and forwards only the chosen camera/microphone booleans. Selected device IDs, speaker routing, and camera selection are not explicitly forwarded to the new meeting session. Cancelling preview aborts connection.
videoAvailable: falsechanges the default tile presentation. It does not disable capture, publishing, or permissions. Use session media controls to disable capture; for an initially camera-off join, let the user turn it off in pre-join. The preview itself initially enables the camera.await join(...)completes after the meeting route closes (or an early return such as preview cancellation).onDisconnectedruns after the meeting route returns, not as a general transport-disconnection event, and does not run on preview cancellation.
Direct client and room workflows
Keep one client for the owning widget/service lifetime; do not create it in
build. Close it after its requests and meetings no longer need its services.
close() releases HTTP resources, not an active meeting. A State that owns
an initialized client can call _client.close() in its dispose() before
super.dispose(); keep that owner alive while its meeting route uses recording.
QriibMeetClient createClient({
required String apiKey,
required String secretKey,
}) => QriibMeetClient.withProjectCredentials(
apiKey: apiKey,
secretKey: secretKey,
baseUrl: 'https://api.qriib.dev/api/v4',
enableNetworkLogging: false,
);
QriibMeetClient createScopedClient({
required QriibProjectCredentials project,
QriibOrganizationCredentials? organization,
}) => QriibMeetClient.withCredentials(
project: project,
organization: organization,
enableNetworkLogging: false,
);
QriibProjectCredentials and QriibOrganizationCredentials each take required
apiKey and secretKey strings. withCredentials requires at least one scope.
All exported room operations require project credentials; organization-only
credentials do not authorize them or enable recording. Network logging defaults
to true; disable it explicitly in sensitive integrations. Body/header logging
is disabled by the client's default logger, but this is not a general redaction
guarantee.
Create and open a quick room
Future<void> createAndOpenRoom({
required BuildContext context,
required QriibMeetClient client,
required String projectId,
required String moderatorId,
required String participantName,
}) async {
final response = await client.rooms.createQuickVideoRoom(
projectId: projectId,
clientRoomId: newClientRoomId(),
moderatorId: moderatorId,
name: participantName,
maxParticipants: 8,
emptyTimeout: 300,
metadata: const QriibRoomMetadata(
roomTitle: 'Team meeting',
welcomeMessage: 'Welcome',
),
);
final link = finalLinkFromResponse(response);
if (link == null) {
throw const QriibMeetException('The response has no final_link.');
}
if (!context.mounted) return;
await client.meetings.join(context: context, finalLink: link);
}
newClientRoomId() generates a cryptographically random UUID-v4-style string;
use a new value for each new room. finalLinkFromResponse trims a nonempty
string link. If data is a map it uses only that map, even when its link is
missing/empty and a top-level link exists. Otherwise it checks the top level.
It does not validate the link or check expiration; join does that later.
Scheduled rooms
Use createScheduledVideoRoom or createScheduledAudioRoom with a startAt string in the format required by your backend. The SDK forwards it unchanged without parsing or validating the schedule. Call startScheduledRoom when it is time to open the room.
Complete Rooms API
Every method returns Future<Map<String, dynamic>>; there is no typed room
response model. Read IDs from the response shape your backend returns. Do not
confuse a server room ID with your generated clientRoomId.
The following function demonstrates all 12 operations as independently callable
actions; invoke only the action intended by the user. All identifiers and dates
are explicit inputs, and end must be confirmed by your app before invocation.
Future<Map<String, dynamic>> roomAction({
required QriibRooms rooms,
required String action,
required String projectId,
required String roomId,
required String participantName,
required String moderatorId,
required DateTime scheduledStart,
}) {
const metadata = QriibRoomMetadata(roomTitle: 'Team meeting');
return switch (action) {
'quick-video' => rooms.createQuickVideoRoom(
projectId: projectId, clientRoomId: newClientRoomId(),
metadata: metadata, name: participantName, moderatorId: moderatorId,
maxParticipants: 8, emptyTimeout: 300),
'quick-audio' => rooms.createQuickAudioRoom(
projectId: projectId, clientRoomId: newClientRoomId(),
metadata: metadata, name: participantName, moderatorId: moderatorId,
maxParticipants: 8, emptyTimeout: 300),
'schedule-video' => rooms.createScheduledVideoRoom(
projectId: projectId, clientRoomId: newClientRoomId(),
startAt: scheduledStart.toUtc().toIso8601String(), metadata: metadata,
maxParticipants: 8, emptyTimeout: 300),
'schedule-audio' => rooms.createScheduledAudioRoom(
projectId: projectId, clientRoomId: newClientRoomId(),
startAt: scheduledStart.toUtc().toIso8601String(), metadata: metadata,
maxParticipants: 8, emptyTimeout: 300),
'start' => rooms.startScheduledRoom(roomId, name: participantName),
'join' => rooms.joinRoom(
roomId: roomId,
userInfo: QriibUserInfo(name: participantName, role: 'attendee',
isAdmin: false, isHidden: false)),
'invite' => rooms.createInvitationLink(
roomId: roomId, role: QriibInvitationRole.attendee),
'status' => rooms.getRoomStatus(roomId),
'active-room' => rooms.getActiveRoomInfo(roomId),
'active-rooms' => rooms.getActiveRoomsInfo(),
'past' => rooms.fetchPastRooms(
projectId: projectId, roomIds: [roomId], from: 0, limit: 20,
orderBy: QriibPastRoomsOrder.desc),
'end' => rooms.endRoom(roomId),
_ => throw ArgumentError.value(action, 'action', 'Unknown room action'),
};
}
- Quick methods require
projectId,clientRoomId, andmetadata; theirname,moderatorId,maxParticipants, andemptyTimeoutare optional. - Scheduled methods require those three fields plus string
startAt, with optionalmaxParticipants/emptyTimeout; they have no name/moderator inputs. CallstartScheduledRoomwhen ready, then use its fresh link. QriibRoomMetadatarequiresroomTitle;welcomeMessageand integerroomDurationare optional.toJson()nests duration underroom_features.QriibUserInforequiresname;role,isAdmin, andisHiddenare optional.toJson()nests role underuser_metadata. The server enforces authorization.- Invitation roles are
adminandattendee. An invitation link is not a substitute for the fresh meetingfinal_linkrequired byjoin. fetchPastRoomsrequires onlyprojectId;roomIds,from,limit, andorderBy(asc/desc) are optional.
Read fresh links from quick-create, join, and scheduled-start responses and use
them immediately. For audio rooms choose an appropriate UI configuration, but
remember that videoAvailable: false is not a media restriction.
Meeting UI, sessions, and recording
- Speaker view and gallery view. Swipe horizontally (or use the top-bar
toggle) to move between a single large stage and an equal grid.
initialViewModepicks the opening layout.enableViewModeSwipe: falsedisables swiping; the optional view-mode button can still switch layouts. In speaker view the stage shows an active screen share first, then a pinned participant (long-press any tile to pin, long-press again to unpin), then an active remote speaker, the last remote speaker still present, or the first remote participant. If there are no remotes, the first participant is used. - Draggable self-view. In speaker view your own camera floats in a small thumbnail that can be dragged anywhere and snaps to the nearest corner.
- Screen sharing.
session.setScreenShareEnabled(true)(or the share control) publishes the screen. The sharer is spotlighted for everyone; in gallery view the other participants collapse into a filmstrip beneath it. On Android the package asks for the screen-capture consent, then starts the requiredmediaProjectionforeground service, then begins capturing — the order Android 14+ enforces — and declares the permissions in its own manifest, so host apps need no extra setup. - Front/back camera.
session.switchCamera()flips lenses; the default control appears only whereQriibMeetingState.canSwitchCameraPositionis true (mobile) and the camera is on. - Live tile badges. Every tile highlights the active speaker with
speakingColor, shows a muted-mic badge, an initial avatar when the camera is off, and a warning glyph whenconnectionQualityis poor or lost.QriibMeetingParticipantexposesisMicrophoneEnabled,isCameraEnabled,isSpeaking,isScreenSharingandconnectionQualityfor custom tiles. - Auto-hiding chrome. The top bar and controls slide away after
controlsAutoHideDelay(5 s by default) and return on tap. Passnullto disable automatic hiding. Tapping the stage can still hide/show them manually. - Meeting timer, participants sheet, leave confirmation. The top bar shows
elapsed time since the view opened (
showMeetingTimer), not server room duration. The bottom-bar Participants button opens the participant sheet (showParticipantsList). Leaving asks for confirmation (confirmLeave) unless you supply your ownonLeaveRequested.
Pre-join screen
The lobby screen is opt-in. Enable it to let participants confirm their camera, microphone, and audio devices before the meeting connects:
final meetingConfiguration = QriibMeetingConfiguration(
preJoin: QriibPreJoinConfiguration(
enabled: true,
title: meetingTitle,
showDeviceSelector: true,
),
);
The camera and microphone states chosen in the lobby are carried into the meeting. Selected device IDs and the speaker-route setting are not explicitly passed into the new meeting connection; do not rely on preview device selection being preserved. Cancelling the lobby aborts the join without connecting.
Session lifecycle and callbacks
userNameanduserImageonjoinare reserved; establish participant identity through room creation or join data.joinwaits for the meeting route to finish. The view automatically exits and invokesonDisconnectedwhen the room is terminated by the server, duration expires, or when the participant leaves; it does not run when pre-join is cancelled.- The default meeting view calls
session.leave()on disposal. A custom Leave button must also close its route; callingsession.leave()alone disconnects the session but does not pop navigation. - Camera/microphone/screen-share callbacks are invoked by successful default
control actions, not every programmatic change. A screen-share request can
finish normally after consent is declined; inspect
session.statefor the actual sharing state rather than treating the callback argument as proof. onLeaveRequestedandconfirmLeaveguard the default Leave action, not all navigation/disposal paths. Custom UI must implement its own navigation policy.
Replace individual regions
The three builders receive neutral Qriib types and a QriibMeetingSession:
final meetingConfiguration = QriibMeetingConfiguration(
participantBuilder: (context, participant, video, session) {
// `video` is the SDK-provided renderer. Do not retain it when local video
// is disabled; listen to the neutral session state instead.
return ValueListenableBuilder<QriibMeetingState>(
valueListenable: session.state,
builder: (context, state, child) {
final localCameraOff = participant.isLocal && !state.cameraEnabled;
if (!localCameraOff) return video;
final initial = participant.label.isEmpty
? fallbackInitial
: participant.label.characters.first.toUpperCase();
return ColoredBox(
color: participantTileColor,
child: Center(child: CircleAvatar(child: Text(initial))),
);
},
);
},
topBarBuilder: (context, state, session) => MeetingHeader(
title: meetingTitle,
participantCount: state.participants.length,
),
controlsBuilder: (context, state, session) => MeetingControls(
cameraEnabled: state.cameraEnabled,
microphoneEnabled: state.microphoneEnabled,
onCameraPressed: () => session.setCameraEnabled(!state.cameraEnabled),
onMicrophonePressed: () =>
session.setMicrophoneEnabled(!state.microphoneEnabled),
onLeavePressed: session.leave,
),
);
When replacing controls, provide the same accessible labels, focus behavior, disabled video indication for audio-only rooms, and a way to leave. When replacing participant tiles, do not render a camera frame when the relevant camera state is off; show an initial/avatar on a fixed background instead.
Audio device configuration
Audio controls depend on capabilities reported by the session. On the declared Android/iOS platforms, use the speaker-route control when available. The underlying engine also has device-list branches; these are not a declaration of desktop/web support for this package.
The built-in audio-route control
The default meeting view ships an audio-route button at the left of its top
bar (next to the flip-camera button; it drops into the bottom bar when
showTopBar is false). Tapping it opens a picker whose contents depend on
what the platform reports:
- Mobile — a single Speaker switch that routes audio between the earpiece and the loudspeaker. Wired headsets and Bluetooth earbuds still take priority while they are connected. The button icon follows the current route.
- Desktop and web — a Microphone list and a Speaker list, each showing the OS-provided device names with the active device marked. Selecting an entry switches to it immediately.
The button only renders when the platform reports at least one of these
capabilities, so nothing appears on a device with a single fixed route. Set
QriibMeetingConfiguration(showAudioDeviceControl: false) to hide it and
build your own picker instead.
The opt-in pre-join lobby offers the same choices before connecting: a
speaker toggle on mobile, and microphone/speaker chips on desktop and web when
QriibPreJoinConfiguration(showDeviceSelector: true). Only the camera/microphone enabled flags are explicitly forwarded from
the lobby to the meeting, not device IDs or the speaker-route setting.
Driving it yourself
QriibMeetingSession (and QriibPreJoinSession before joining) exposes the
same state and operations to your own builders:
final state = session.state.value;
// Mobile: toggle the speaker on/off; wired/Bluetooth devices still take
// priority when connected.
await session.setSpeakerphoneOn(!state.speakerOn);
// Desktop/web: target a specific device by id.
if (state.canSelectSpecificDevice && state.audioInputs.isNotEmpty) {
await session.selectAudioInput(state.audioInputs.first);
}
if (state.canSelectSpecificDevice && state.audioOutputs.isNotEmpty) {
await session.selectAudioOutput(state.audioOutputs.first);
}
state.audioInputs and state.audioOutputs are lists of QriibAudioDevice
(id, label, and a kind of QriibAudioDeviceKind.input or .output);
state.selectedAudioInputId and state.selectedAudioOutputId identify the
active ones. Check state.canToggleSpeakerphone (mobile) or
state.canSelectSpecificDevice (desktop/web) to decide which control to show
— rendering a device list on a platform that only supports a speaker toggle
leaves the user with a picker that cannot change anything.
const meetingConfiguration = QriibMeetingConfiguration(
title: 'Team meeting',
theme: QriibMeetingTheme(primaryColor: Color(0xFF00695C)),
texts: QriibMeetingTexts(
mute: 'Mute microphone',
record: 'Start recording',
stopRecording: 'Stop recording',
recordingError: 'Unable to confirm the request. Check before retrying.',
),
showViewModeToggle: true,
initialViewMode: QriibMeetingViewMode.gallery,
controlsAutoHideDelay: null,
preJoin: QriibPreJoinConfiguration(enabled: true, title: 'Ready to join?'),
);
controlsAutoHideDelay: null disables the timer, not manual tap-to-hide.
Disabling swipes does not disable an enabled view-mode toggle. Visibility flags
control default UI, not permissions, server capabilities, or your replacement
builders.
The client also provides createInvitationLink, endRoom, getRoomStatus,
getActiveRoomInfo, getActiveRoomsInfo, and fetchPastRooms. Catch
QriibApiException and QriibMeetException and display application-owned
messages. Exception messages and raw responses can include server-provided
content; they are not automatically safe to display or log.
See meeting UI and public session reference for every flag, theme field, builder, callback, audio-device operation, and view lifecycle. See screen sharing for Android/iOS behavior.
Unreleased: server-side recording requires project
credentials, an outer tool_token in the fresh link, and a room ID. The SDK
layout enum serializes speaker or gallery; it has no grid value. Backend
acceptance is separate: do not assume gallery aliases grid.
state.recordingStatus reports the media room's observed active/inactive state
or unknown; the legacy isRecording is only local command state. After a
reconnect, the room value can be cached until a fresh update arrives. A successful
HTTP request is not the same as room confirmation, and neither indicates that a
recording file is ready. Use a base URL ending in /api/v4; supplying /api/v5
currently duplicates the recording path.
Tool-token renewal is disabled by default. Opt in with
QriibMeetingConfiguration(trustedToolTokenRenewalEndpoint: Uri.parse('wss://trusted.example/socket')).
Only use an endpoint trusted with credentials. Renewal is best effort; actual
backend compatibility has not been verified. See the recording guide
for prerequisites and security limitations.
Errors and cleanup
Room requests map HTTP 400/422 to QriibValidationException, 401/403 to
QriibAuthenticationException, 404 to QriibNotFoundException, and 429 to
QriibRateLimitException; all extend QriibApiException. Other room failures
and recording HTTP failures use the base exception. Its message, nullable
statusCode, and response can contain server/transport details: do not assume
they are safe to display or log verbatim.
QriibMeetException covers meeting/link errors; QriibSessionException and
QriibNetworkException extend it. Catch errors at your async UI boundary,
present an app-owned message, and retain only sanitized diagnostics. There is
no blanket automatic retry policy. In particular, do not blindly repeat room
creation or recording commands after an ambiguous timeout.
Check mounted/context.mounted after awaits before navigation or state updates.
The built-in meeting view leaves its session when disposed; pre-join sessions
created by join are disposed by that flow. Custom session calls do not invoke
the default UI callbacks or pop routes automatically. A custom leave control
must await session.leave() and then pop its route if still mounted. Do not
retain sessions or their listenables after leave/disposal.
More documentation
- Meeting UI and customization
- Screen sharing
- Recording (unreleased)
- Troubleshooting, including Android surface callback compilation errors
- Minimal compilable example
- Example playground
- Backend-issued-link widget
- Package quality and release checklist
- Release history
- Published SDK documentation
Historical design records under specs/ and docs/superpowers/ are
repository-only material, excluded from the published package. They are not a
promise of current public API support.
Libraries
- qriib_meet
- Room-management APIs and customizable Flutter meeting views for Qriib.