What you build
A meeting is one room where everyone publishes their microphone and camera. It works like the one-to-one call, with more people: install the SDK and add the permissions as shown there. Use one room name per meeting, for example the meeting's ID in your app.
- A grid with one tile per person, and a large view for a shared screen.
- Mute and camera buttons, a muted icon on tiles, and a highlight on whoever is talking.
- Your server decides who may join and what each person may publish.
1. Who may join, from your server
Your token endpoint is where host rules live. Sign a token only for people invited to the meeting, and use canPublishSources to limit what each person may send. In this example everyone talks and shows a camera, and only the host may share a screen.
// server.ts (your backend). The token endpoint from the docs, with meeting rules.
app.post('/rtc/token', requireUser, async (req, res) => {
const room = req.body?.room;
if (typeof room !== 'string' || !/^[\w.-]{1,64}$/.test(room)) {
return res.status(400).json({ error: 'invalid room' });
}
// Your own data: who is invited, and who runs the meeting.
if (!(await isInvited(room, req.user.id))) {
return res.status(403).json({ error: 'not invited' });
}
const isHost = await isMeetingHost(room, req.user.id);
const token = await createToken(APP_ID, APP_CERTIFICATE, {
room,
identity: req.user.id,
name: req.user.name,
canPublish: true,
// Everyone talks and shows a camera. Here only the host may share a screen.
canPublishSources: isHost
? ['camera', 'microphone', 'screen_share', 'screen_share_audio']
: ['camera', 'microphone'],
ttlSeconds: 60 * 60,
});
res.json({ token, url: REGION_URL });
});canPublishSources takes camera, microphone, screen_share and screen_share_audio. With canPublish: false a person can watch and listen but not send anything. All token options are on the Server API page.
2. Create the room and the grid
Create the token provider and the room, as in the call guide.
import 'package:gravix_rtc/gravix_rtc.dart';
// One token provider per signed-in user. It posts { room, identity, name, can_publish }
// to YOUR endpoint and caches each token until shortly before it expires.
final tokens = GravixTokenProvider.endpoint(
Uri.parse('https://YOUR-BACKEND/rtc/token'),
headers: {'Authorization': 'Bearer $sessionJwt'}, // your app's own login session
);
// One room service per call, meeting or class screen.
final room = GravixRoomService();import { Room, GravixTokenProvider } from 'gravix-rtc';
// One token provider for the app. It posts { room, identity, name, can_publish }
// to YOUR endpoint with the user's session cookie, and caches each token.
const tokens = GravixTokenProvider.endpoint('https://YOUR-BACKEND/rtc/token', {
credentials: 'include',
});
// One Room per call, meeting or class screen.
const room = new Room();Then set up the grid before you join, so people already in the meeting appear too. Camera tracks go into tiles. A shared screen is a separate video track: check its source and show it large.
// Set these before you join. One camera per user ID, and any shared screens.
final cameras = <String, VideoTrack>{};
final screens = <String, VideoTrack>{};
room.onRemoteVideoTrack = (uid, track) => setState(() {
if (track.source == TrackSource.screenShareVideo) {
screens[uid] = track;
} else {
cameras[uid] = track;
}
});
// This callback only says whose video went away. Check what that user still publishes.
room.onRemoteVideoTrackRemoved = (uid) => setState(() {
final p = room.room?.remoteParticipants[uid];
if (p?.getTrackPublicationBySource(TrackSource.camera) == null) cameras.remove(uid);
if (p?.getTrackPublicationBySource(TrackSource.screenShareVideo) == null) screens.remove(uid);
});
room.onUserOffline = (uid) => setState(() {
cameras.remove(uid);
screens.remove(uid);
});
// The grid: your own camera first, then everyone else.
final me = room.localVideoTrack;
GridView.count(
crossAxisCount: 2,
children: [
if (me != null) VideoTrackRenderer(me, fit: VideoViewFit.cover),
for (final track in cameras.values) VideoTrackRenderer(track, fit: VideoViewFit.cover),
],
);import { RoomEvent, Track, type Participant } from 'gravix-rtc';
const grid = document.getElementById('grid')!;
const screen = document.getElementById('screen')!;
// One tile per person, keyed by their identity (your user ID).
const tiles = new Map<string, HTMLElement>();
function tileFor(p: Participant) {
let tile = tiles.get(p.identity);
if (!tile) {
tile = document.createElement('div');
tile.className = 'tile';
tile.dataset.name = p.name || p.identity;
grid.appendChild(tile);
tiles.set(p.identity, tile);
}
return tile;
}
// Register before you join.
room
.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
const el = track.attach();
if (publication.source === Track.Source.ScreenShare) screen.appendChild(el);
else tileFor(participant).appendChild(el);
})
.on(RoomEvent.TrackUnsubscribed, (track) => {
track.detach().forEach((el) => el.remove());
})
.on(RoomEvent.ParticipantConnected, (participant) => tileFor(participant))
.on(RoomEvent.ParticipantDisconnected, (participant) => {
tiles.get(participant.identity)?.remove();
tiles.delete(participant.identity);
});
// After the join: people already in the meeting, and your own camera.
// room.remoteParticipants.forEach((p) => tileFor(p));
// tileFor(room.localParticipant).appendChild(camera.videoTrack.attach());On Flutter, onUserJoined also gives you the user ID of someone who joins without a camera, if you want a tile for them. Everyone's audio plays on its own.
3. Join the meeting
final ok = await room.connectWithTokenProvider(
tokenProvider: tokens,
request: GravixTokenRequest(
room: meetingId,
identity: userId,
name: userName,
canPublish: true,
),
publishMic: true,
enableVideo: true,
);import { connectWithTokenProvider } from 'gravix-rtc';
await connectWithTokenProvider(room, {
tokenProvider: tokens,
request: { room: meetingId, identity: userId, name: userName, canPublish: true },
});
await room.localParticipant.setMicrophoneEnabled(true);
const camera = await room.localParticipant.setCameraEnabled(true);Your own camera: room.localVideoTrack on Flutter, and camera?.videoTrack?.attach() on the web. If the browser blocks sound, show a button that calls room.startAudio().
4. Mute and unmute
// Mute and unmute yourself.
await room.setMicEnabled(false);
await room.setMicEnabled(true);
// Show a muted icon on someone's tile.
room.onUserMuteAudio = (uid, muted) => setState(() {
if (muted) {
mutedUsers.add(uid);
} else {
mutedUsers.remove(uid);
}
});import { RoomEvent, Track } from 'gravix-rtc';
// Mute and unmute yourself.
await room.localParticipant.setMicrophoneEnabled(false);
await room.localParticipant.setMicrophoneEnabled(true);
// Show a muted icon on someone's tile.
room
.on(RoomEvent.TrackMuted, (publication, participant) => {
if (publication.kind === Track.Kind.Audio) tileFor(participant).classList.add('muted');
})
.on(RoomEvent.TrackUnmuted, (publication, participant) => {
if (publication.kind === Track.Kind.Audio) tileFor(participant).classList.remove('muted');
});Camera on and off works the same way: room.setCameraEnabled() on Flutter, setCameraEnabled() on the web.
5. Show who is talking
The SDK tells you who is speaking right now. Use it to light up their tile, or to put the speaker in the large view.
// The user IDs talking right now. Your own ID is in it when you talk.
ValueListenableBuilder<Set<String>>(
valueListenable: room.activeSpeakers,
builder: (context, speaking, _) => DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: speaking.contains(uid) ? Colors.green : Colors.transparent,
width: 3,
),
),
child: VideoTrackRenderer(cameras[uid]!, fit: VideoViewFit.cover),
),
);import { RoomEvent } from 'gravix-rtc';
// The people talking right now, loudest first.
room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
const talking = new Set(speakers.map((s) => s.identity));
tiles.forEach((tile, id) => tile.classList.toggle('speaking', talking.has(id)));
});6. Screen sharing
One call shares the screen and one call stops it. Everyone else receives it as a video track with the screen-share source, which the grid above already puts in the large view.
- Android: the SDK asks the user for permission to capture the screen and runs the screen-capture service Android 14 needs. Nothing to add to your manifest.
- iOS: sharing the whole screen needs a Broadcast Upload Extension and an App Group in your app. The
gravix_rtcpackage ships a ready extension inios/BroadcastExtension/with step-by-step setup; see the Screen share section of the package page on pub.dev. - Web: desktop browsers. Mobile browsers generally do not support screen capture.
- The token must allow it: leave out
canPublishSources, or includescreen_sharein it.
Host controls
What you can do today, from your own code:
- Who gets in. Your token endpoint signs tokens only for invited people. To keep someone out, stop signing tokens for them. Keep tokens short-lived.
- What each person may send.
canPublishandcanPublishSourcesin the token, as in step 1. A person who is allowed more later joins again with a new token; the classrooms guide shows how. - Mute someone for yourself. Stop hearing one person on your own device. The others still hear them.
// Stop hearing one person on this device only. Others still hear them.
await room.muteRemoteAudio(uid, true);
await room.muteRemoteAudio(uid, false); // hear them again// Stop hearing one person on this device only. Others still hear them.
room.remoteParticipants.get(theirUserId)?.setVolume(0);
room.remoteParticipants.get(theirUserId)?.setVolume(1); // hear them againA host can also mute a participant for everyone, or remove someone from a meeting that is already running. These controls are set up with our team for your app. Contact us to enable them.
Cloud recording
Meetings can be recorded in the cloud. Recording is set up with our team, including where the files go. Contact us to set it up.
While a room is being recorded, the SDKs tell every participant, so you can show a recording sign: room.isRecording and RoomEvent.RecordingStatusChanged on the web, and room.room?.isRecording on Flutter.
Full sample
One meeting screen: the grid with your own camera first, a large view for a shared screen, a highlight on whoever is talking, and mute, camera, share and leave buttons.
import 'package:flutter/material.dart';
import 'package:gravix_rtc/gravix_rtc.dart';
/// A video meeting: everyone publishes camera and mic, everyone sees a grid.
class MeetingPage extends StatefulWidget {
const MeetingPage({
super.key,
required this.tokens,
required this.meetingId,
required this.userId,
required this.userName,
});
final GravixTokenProvider tokens;
final String meetingId;
final String userId;
final String userName;
@override
State<MeetingPage> createState() => _MeetingPageState();
}
class _MeetingPageState extends State<MeetingPage> {
final room = GravixRoomService();
final cameras = <String, VideoTrack>{}; // user ID -> camera
final screens = <String, VideoTrack>{}; // user ID -> shared screen
bool sharing = false;
String? error;
@override
void initState() {
super.initState();
room.onRemoteVideoTrack = (uid, track) {
if (!mounted) return;
setState(() {
if (track.source == TrackSource.screenShareVideo) {
screens[uid] = track;
} else {
cameras[uid] = track;
}
});
};
// This callback only says whose video went away. Check what that user still publishes.
room.onRemoteVideoTrackRemoved = (uid) {
if (!mounted) return;
final p = room.room?.remoteParticipants[uid];
setState(() {
if (p?.getTrackPublicationBySource(TrackSource.camera) == null) cameras.remove(uid);
if (p?.getTrackPublicationBySource(TrackSource.screenShareVideo) == null) screens.remove(uid);
});
};
room.onUserOffline = (uid) {
if (!mounted) return;
setState(() {
cameras.remove(uid);
screens.remove(uid);
});
};
_join();
}
Future<void> _join() async {
final ok = await room.connectWithTokenProvider(
tokenProvider: widget.tokens,
request: GravixTokenRequest(
room: widget.meetingId,
identity: widget.userId,
name: widget.userName,
canPublish: true,
),
publishMic: true,
enableVideo: true,
);
if (!ok && mounted) {
setState(() => error = room.lastTokenError?.message ?? 'Could not join the meeting');
}
}
Future<void> _toggleShare() async {
try {
// Android asks the user first. iOS needs a Broadcast Upload Extension.
await room.localParticipant?.setScreenShareEnabled(!sharing);
if (mounted) setState(() => sharing = !sharing);
} catch (e) {
debugPrint('Screen share failed: $e');
}
}
@override
void dispose() {
room.disconnect().whenComplete(room.dispose);
super.dispose();
}
@override
Widget build(BuildContext context) {
if (error != null) return Center(child: Text(error!));
final shared = screens.values.firstOrNull;
return Column(
children: [
if (shared != null)
AspectRatio(aspectRatio: 16 / 9, child: VideoTrackRenderer(shared, fit: VideoViewFit.contain)),
Expanded(child: _grid()),
Padding(padding: const EdgeInsets.all(16), child: _controls()),
],
);
}
// One tile per camera. The tile of whoever is talking gets a green border.
Widget _grid() => ValueListenableBuilder<Set<String>>(
valueListenable: room.activeSpeakers,
builder: (context, speaking, _) {
final me = room.localVideoTrack;
return GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
padding: const EdgeInsets.all(8),
children: [
if (me != null) _tile(me, speaking.contains(widget.userId)),
for (final e in cameras.entries) _tile(e.value, speaking.contains(e.key)),
],
);
},
);
Widget _tile(VideoTrack track, bool speaking) => DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: speaking ? Colors.green : Colors.transparent, width: 3),
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: VideoTrackRenderer(track, fit: VideoViewFit.cover),
),
);
Widget _controls() => Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ValueListenableBuilder<bool>(
valueListenable: room.isMicMuted,
builder: (context, muted, _) => IconButton.filled(
tooltip: muted ? 'Unmute' : 'Mute',
icon: Icon(muted ? Icons.mic_off : Icons.mic),
onPressed: () => room.setMicEnabled(muted),
),
),
const SizedBox(width: 16),
ValueListenableBuilder<bool>(
valueListenable: room.isCameraEnabled,
builder: (context, on, _) => IconButton.filled(
tooltip: on ? 'Camera off' : 'Camera on',
icon: Icon(on ? Icons.videocam : Icons.videocam_off),
onPressed: () => room.setCameraEnabled(!on),
),
),
const SizedBox(width: 16),
IconButton.filled(
tooltip: sharing ? 'Stop sharing' : 'Share screen',
icon: Icon(sharing ? Icons.stop_screen_share : Icons.screen_share),
onPressed: _toggleShare,
),
const SizedBox(width: 16),
IconButton.filled(
tooltip: 'Leave',
icon: const Icon(Icons.call_end),
onPressed: () => Navigator.of(context).pop(),
),
],
);
}import { useEffect, useRef, useState } from 'react';
import {
Room, RoomEvent, Track, GravixTokenProvider, connectWithTokenProvider, type Participant,
} from 'gravix-rtc';
const tokens = GravixTokenProvider.endpoint('https://YOUR-BACKEND/rtc/token', {
credentials: 'include',
});
type Props = { meetingId: string; userId: string; userName: string };
export function Meeting({ meetingId, userId, userName }: Props) {
const gridRef = useRef<HTMLDivElement>(null);
const screenRef = useRef<HTMLDivElement>(null);
const roomRef = useRef<Room | null>(null);
const [joined, setJoined] = useState(false);
const [micOn, setMicOn] = useState(true);
const [camOn, setCamOn] = useState(true);
const [sharing, setSharing] = useState(false);
const [recording, setRecording] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
const grid = gridRef.current!;
const screen = screenRef.current!;
const room = new Room();
roomRef.current = room;
let cancelled = false;
// One tile per person, keyed by their identity (your user ID).
const tiles = new Map<string, HTMLElement>();
const tileFor = (p: Participant) => {
let tile = tiles.get(p.identity);
if (!tile) {
tile = document.createElement('div');
tile.className = 'tile';
tile.dataset.name = p.name || p.identity;
grid.appendChild(tile);
tiles.set(p.identity, tile);
}
return tile;
};
room
.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
const el = track.attach();
if (publication.source === Track.Source.ScreenShare) screen.appendChild(el);
else tileFor(participant).appendChild(el);
})
.on(RoomEvent.TrackUnsubscribed, (track) => {
track.detach().forEach((el) => el.remove());
})
.on(RoomEvent.ParticipantConnected, (participant) => {
tileFor(participant); // a tile even before their camera arrives
})
.on(RoomEvent.ParticipantDisconnected, (participant) => {
tiles.get(participant.identity)?.remove();
tiles.delete(participant.identity);
})
// Who is talking right now: highlight their tiles.
.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
const talking = new Set(speakers.map((s) => s.identity));
tiles.forEach((tile, id) => tile.classList.toggle('speaking', talking.has(id)));
})
// Show who has their mic off.
.on(RoomEvent.TrackMuted, (publication, participant) => {
if (publication.kind === Track.Kind.Audio) tileFor(participant).classList.add('muted');
})
.on(RoomEvent.TrackUnmuted, (publication, participant) => {
if (publication.kind === Track.Kind.Audio) tileFor(participant).classList.remove('muted');
})
// The browser's own "Stop sharing" button ends the share too.
.on(RoomEvent.LocalTrackUnpublished, (publication) => {
if (publication.source === Track.Source.ScreenShare) setSharing(false);
})
.on(RoomEvent.RecordingStatusChanged, (isRecording) => setRecording(isRecording));
(async () => {
try {
await connectWithTokenProvider(room, {
tokenProvider: tokens,
request: { room: meetingId, identity: userId, name: userName, canPublish: true },
});
if (cancelled) return;
room.remoteParticipants.forEach((p) => tileFor(p)); // people already in the meeting
await room.localParticipant.setMicrophoneEnabled(true);
const camera = await room.localParticipant.setCameraEnabled(true);
const self = camera?.videoTrack?.attach();
if (self && !cancelled) tileFor(room.localParticipant).appendChild(self);
if (!cancelled) {
setRecording(room.isRecording);
setJoined(true);
}
} catch {
if (!cancelled) setError('Could not join the meeting');
}
})();
return () => {
cancelled = true;
roomRef.current = null;
void room.disconnect();
grid.replaceChildren();
screen.replaceChildren();
};
}, [meetingId, userId, userName]);
const me = () => roomRef.current?.localParticipant;
async function toggleMic() {
const p = me();
if (!p) return;
await p.setMicrophoneEnabled(!p.isMicrophoneEnabled);
setMicOn(p.isMicrophoneEnabled);
}
async function toggleCamera() {
const p = me();
if (!p) return;
await p.setCameraEnabled(!p.isCameraEnabled);
setCamOn(p.isCameraEnabled);
}
async function toggleShare() {
const p = me();
if (!p) return;
try {
await p.setScreenShareEnabled(!p.isScreenShareEnabled); // the browser asks what to share
} catch {
// the user cancelled the browser's picker
}
setSharing(p.isScreenShareEnabled);
}
return (
<section className="meeting">
{error && <p role="alert">{error}</p>}
{recording && <p className="rec">Recording</p>}
<div ref={screenRef} className="meeting-screen" />
<div ref={gridRef} className="meeting-grid" />
{joined && (
<div className="meeting-controls">
<button type="button" onClick={toggleMic}>{micOn ? 'Mute' : 'Unmute'}</button>
<button type="button" onClick={toggleCamera}>{camOn ? 'Camera off' : 'Camera on'}</button>
<button type="button" onClick={toggleShare}>{sharing ? 'Stop sharing' : 'Share screen'}</button>
<button type="button" onClick={() => roomRef.current?.disconnect()}>Leave</button>
</div>
)}
</section>
);
}The share button shows for everyone in this sample. Hide it for people your server does not allow to share, or the share fails for them. React: give .meeting-grid a grid layout and .tile.speaking a border in your CSS.
Stuck? Talk to a person, or let your AI coding assistant do the wiring with the AI assistant guide.
