Docs

Video meetings and group calls

Many people in one room, each with camera and microphone. Show everyone in a grid, highlight who is talking and share a screen. Samples for Flutter and React.

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

lib/meeting.dart
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();
meeting.ts
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.

lib/meeting.dart
// 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),
  ],
);
meeting.ts
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

lib/meeting.dart
final ok = await room.connectWithTokenProvider(
  tokenProvider: tokens,
  request: GravixTokenRequest(
    room: meetingId,
    identity: userId,
    name: userName,
    canPublish: true,
  ),
  publishMic: true,
  enableVideo: true,
);
meeting.ts
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

lib/meeting.dart
// 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);
  }
});
meeting.ts
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.

lib/meeting.dart
// 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),
  ),
);
meeting.ts
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.

lib/meeting.dart
// Start sharing your screen. Android asks the user first.
// iOS needs a Broadcast Upload Extension (see below).
await room.localParticipant?.setScreenShareEnabled(true);

// Stop sharing.
await room.localParticipant?.setScreenShareEnabled(false);

// Others receive it through onRemoteVideoTrack, as a track whose
// source is TrackSource.screenShareVideo (see the grid above).
meeting.ts
import { RoomEvent, Track } from 'gravix-rtc';

// Start sharing. The browser asks which screen, window or tab.
await room.localParticipant.setScreenShareEnabled(true);

// Stop sharing.
await room.localParticipant.setScreenShareEnabled(false);

// The browser's own "Stop sharing" button ends it too. Update your button:
room.on(RoomEvent.LocalTrackUnpublished, (publication) => {
  if (publication.source === Track.Source.ScreenShare) showShareButton();
});
  • 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_rtc package ships a ready extension in ios/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 include screen_share in 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. canPublish and canPublishSources in 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.
lib/meeting.dart
// 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
meeting.ts
// 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 again

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

lib/meeting_page.dart
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(),
          ),
        ],
      );
}
Meeting.tsx
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.

Build meetings into your app

Sign up free for your App ID, App Certificate and 10,000 free minutes. For host controls and recording, talk to our team.