What you build
A one-to-one call is a room with two people in it. Both publish their microphone, and for a video call their camera too.
- Your server picks the room name for the pair and only lets those two users in.
- Each app joins that room, shows the other person's video and has mute, camera and hang-up buttons.
Ringing the other phone is up to your app, for example with a push notification that carries the room name.Gravix Cloud carries the call once both people join.
1. Install and permissions
flutter pub add gravix_rtcnpm install gravix-rtcyarn add gravix-rtcpnpm add gravix-rtcBoth people use the microphone, and the camera in a video call. On Flutter, declare both for each platform and ask for them at runtime before the call starts. In the browser there is nothing to declare: the browser asks the first time, on HTTPS pages only.
<!-- android/app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- Bluetooth headsets: Android 12+ needs BLUETOOTH_CONNECT -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<!-- ... your <application> ... -->
</manifest><!-- ios/Runner/Info.plist -->
<key>NSMicrophoneUsageDescription</key>
<string>Talk in calls</string>
<key>NSCameraUsageDescription</key>
<string>Video in calls</string>
<!-- Keep call audio playing with the screen locked or the app in the background -->
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>Keeping a call alive with the app in the background on Android is covered on the SDKs page.
2. A room for two, from your server
Every join needs an access token from your server (see your token endpoint). For calls, the server also picks the room. Here it hashes the two user IDs in sorted order, so the same pair always meets in the same room, whoever calls whom. The token endpoint then lets only those two people in.
// server.ts (your backend). Two endpoints: start a call, and the token endpoint.
import { createHash } from 'node:crypto';
// The same two users always get the same room, whoever calls whom.
function callRoomFor(a: string, b: string): string {
const pair = [a, b].sort().join(':');
return 'call_' + createHash('sha256').update(pair).digest('hex').slice(0, 32);
}
// 1. Start a call. The app sends the other user's ID and gets the room name back.
app.post('/calls', requireUser, async (req, res) => {
const peerId = req.body?.peerId;
if (typeof peerId !== 'string' || !(await mayCall(req.user.id, peerId))) {
return res.status(403).json({ error: 'not allowed' });
}
const room = callRoomFor(req.user.id, peerId);
await saveCall(room, [req.user.id, peerId]); // your own store
await ring(peerId, room); // your own push notification
res.json({ room });
});
// 2. Tokens: only the two people in the call get into its room.
app.post('/rtc/token', requireUser, async (req, res) => {
const room = req.body?.room;
if (typeof room !== 'string' || !(await isInCall(room, req.user.id))) {
return res.status(403).json({ error: 'not in this call' });
}
const token = await createToken(APP_ID, APP_CERTIFICATE, {
room,
identity: req.user.id,
name: req.user.name,
canPublish: true, // both people talk and show their camera
ttlSeconds: 60 * 60,
});
res.json({ token, url: REGION_URL });
});mayCall, saveCall, ring and isInCall are your own code. If your app has other kinds of rooms too, check the call rule only for rooms that start with call_.
3. Create the room
Create a token provider that points at your token endpoint, and one room object for the call screen.
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();4. Show the video
Set up the video before you join, so you also catch someone who is already in the room. The other person's camera arrives as a remote video track. Their audio plays on its own.
// Set the callbacks before you join: someone already in the room is reported during the join.
VideoTrack? remoteVideo;
room.onRemoteVideoTrack = (uid, track) => setState(() => remoteVideo = track);
room.onRemoteVideoTrackRemoved = (uid) => setState(() => remoteVideo = null);
// Big: the other person. Small, in a corner: your own camera (room.localVideoTrack).
remoteVideo == null
? const Text('Waiting for the other person…')
: VideoTrackRenderer(remoteVideo!, fit: VideoViewFit.cover);import { RoomEvent, Track } from 'gravix-rtc';
const remoteBox = document.getElementById('remote')!;
const selfBox = document.getElementById('self')!;
// Register before you join. The other person's camera and mic arrive as subscribed tracks.
room
.on(RoomEvent.TrackSubscribed, (track) => {
remoteBox.appendChild(track.attach()); // a <video> or <audio> element
})
.on(RoomEvent.TrackUnsubscribed, (track) => {
track.detach().forEach((el) => el.remove());
});
// After the join (next step): your own camera, small in a corner.
export function showSelf() {
const camera = room.localParticipant.getTrackPublication(Track.Source.Camera);
const preview = camera?.videoTrack?.attach();
if (preview) selfBox.appendChild(preview);
}5. Join the call
Both people run the same code with the room name from POST /calls.
// callRoom comes from your server (POST /calls). Both people join it.
final ok = await room.connectWithTokenProvider(
tokenProvider: tokens,
request: GravixTokenRequest(
room: callRoom,
identity: userId,
name: userName,
canPublish: true,
),
publishMic: true,
enableVideo: true, // false for a voice-only call
);
if (!ok) {
// room.lastTokenError?.message says why, when your token endpoint failed
}import { connectWithTokenProvider } from 'gravix-rtc';
// callRoom comes from your server (POST /calls). Both people join it.
await connectWithTokenProvider(room, {
tokenProvider: tokens,
request: { room: callRoom, identity: userId, name: userName, canPublish: true },
});
await room.localParticipant.setMicrophoneEnabled(true);
await room.localParticipant.setCameraEnabled(true); // leave out for a voice-only call
showSelf();- Voice-only call: Flutter
enableVideo: false; on the web, leave outsetCameraEnabled. - Browsers can block sound until the user clicks. When
room.canPlaybackAudiois false, show a button that callsroom.startAudio().
6. Mic and camera
// Mic. isMicMuted is a ValueNotifier<bool>: build the button from it.
await room.setMicEnabled(room.isMicMuted.value); // muted -> on, on -> muted
// Camera on and off, and front or back.
await room.setCameraEnabled(!room.isCameraEnabled.value);
await room.switchCamera();const me = room.localParticipant;
// Mic and camera on and off.
await me.setMicrophoneEnabled(!me.isMicrophoneEnabled);
await me.setCameraEnabled(!me.isCameraEnabled);7. Hang up
Leave when either person hangs up or the screen closes. The other side sees you leave the room.
// The other person hung up.
room.onUserOffline = (uid) => Navigator.of(context).pop();
// You hang up (also when the screen closes).
await room.disconnect();
await room.dispose();import { RoomEvent } from 'gravix-rtc';
// The other person hung up.
room.on(RoomEvent.ParticipantDisconnected, () => room.disconnect());
room.on(RoomEvent.Disconnected, () => showCallEnded());
// You hang up. This also stops your camera and mic.
await room.disconnect();Full sample
One call screen with the other person large, your own camera in a corner, and mute, camera and hang-up buttons.
import 'package:flutter/material.dart';
import 'package:gravix_rtc/gravix_rtc.dart';
/// A one-to-one call. callRoom comes from your server (POST /calls),
/// so both users join the same room.
class CallPage extends StatefulWidget {
const CallPage({
super.key,
required this.tokens,
required this.callRoom,
required this.userId,
required this.userName,
this.video = true,
});
final GravixTokenProvider tokens;
final String callRoom;
final String userId;
final String userName;
final bool video; // false = voice-only call
@override
State<CallPage> createState() => _CallPageState();
}
class _CallPageState extends State<CallPage> {
final room = GravixRoomService();
VideoTrack? remoteVideo; // the other person's camera
String? error;
@override
void initState() {
super.initState();
// Set the callbacks before joining: someone already in the room is reported during the join.
room.onRemoteVideoTrack = (uid, track) {
if (mounted) setState(() => remoteVideo = track);
};
room.onRemoteVideoTrackRemoved = (uid) {
if (mounted) setState(() => remoteVideo = null);
};
// The other person hung up.
room.onUserOffline = (uid) {
if (mounted) Navigator.of(context).pop();
};
_join();
}
Future<void> _join() async {
final ok = await room.connectWithTokenProvider(
tokenProvider: widget.tokens,
request: GravixTokenRequest(
room: widget.callRoom,
identity: widget.userId,
name: widget.userName,
canPublish: true,
),
publishMic: true,
enableVideo: widget.video,
);
if (!ok && mounted) {
setState(() => error = room.lastTokenError?.message ?? 'Could not start the call');
}
}
@override
void dispose() {
room.disconnect().whenComplete(room.dispose);
super.dispose();
}
@override
Widget build(BuildContext context) {
if (error != null) return Center(child: Text(error!));
return Stack(
children: [
Positioned.fill(child: _remote()),
if (widget.video) Positioned(top: 16, right: 16, width: 110, height: 160, child: _self()),
Positioned(left: 0, right: 0, bottom: 32, child: _controls()),
],
);
}
// Big: the other person.
Widget _remote() {
final track = remoteVideo;
if (track == null) return const Center(child: Text('Waiting for the other person…'));
return VideoTrackRenderer(track, fit: VideoViewFit.cover);
}
// Small, in the corner: your own camera.
Widget _self() => ValueListenableBuilder<bool>(
valueListenable: room.isCameraEnabled,
builder: (context, on, _) {
final track = room.localVideoTrack;
if (!on || track == null) return const SizedBox.shrink();
return ClipRRect(
borderRadius: BorderRadius.circular(12),
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),
),
),
if (widget.video) ...[
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: 'Flip camera',
icon: const Icon(Icons.cameraswitch),
onPressed: room.switchCamera,
),
],
const SizedBox(width: 16),
IconButton.filled(
tooltip: 'Hang up',
icon: const Icon(Icons.call_end),
onPressed: () => Navigator.of(context).pop(),
),
],
);
}import { useEffect, useRef, useState } from 'react';
import { Room, RoomEvent, GravixTokenProvider, connectWithTokenProvider } from 'gravix-rtc';
// One provider for the whole app. It sends the user's session cookie to YOUR backend.
const tokens = GravixTokenProvider.endpoint('https://YOUR-BACKEND/rtc/token', {
credentials: 'include',
});
// callRoom comes from your server (POST /calls), so both users join the same room.
type Props = { callRoom: string; userId: string; userName: string; video?: boolean };
type Status = 'calling' | 'connected' | 'ended' | 'error';
export function VideoCall({ callRoom, userId, userName, video = true }: Props) {
const remoteRef = useRef<HTMLDivElement>(null);
const selfRef = useRef<HTMLDivElement>(null);
const roomRef = useRef<Room | null>(null);
const [status, setStatus] = useState<Status>('calling');
const [micOn, setMicOn] = useState(true);
const [camOn, setCamOn] = useState(video);
const [audioBlocked, setAudioBlocked] = useState(false);
useEffect(() => {
const remote = remoteRef.current!;
const self = selfRef.current!;
const room = new Room();
roomRef.current = room;
let cancelled = false;
room
// The other person's camera and mic.
.on(RoomEvent.TrackSubscribed, (track) => {
remote.appendChild(track.attach()); // a <video> or <audio> element
if (!cancelled) setStatus('connected');
})
.on(RoomEvent.TrackUnsubscribed, (track) => {
track.detach().forEach((el) => el.remove());
})
// The other person hung up.
.on(RoomEvent.ParticipantDisconnected, () => {
void room.disconnect();
})
.on(RoomEvent.Disconnected, () => {
if (!cancelled) setStatus('ended');
})
.on(RoomEvent.AudioPlaybackStatusChanged, () => {
setAudioBlocked(!room.canPlaybackAudio);
});
(async () => {
try {
await connectWithTokenProvider(room, {
tokenProvider: tokens,
request: { room: callRoom, identity: userId, name: userName, canPublish: true },
});
if (cancelled) return;
await room.localParticipant.setMicrophoneEnabled(true);
if (video) {
// Your own camera, small in the corner.
const camera = await room.localParticipant.setCameraEnabled(true);
const preview = camera?.videoTrack?.attach();
if (preview && !cancelled) self.appendChild(preview);
}
} catch {
if (!cancelled) setStatus('error');
}
})();
return () => {
cancelled = true;
roomRef.current = null;
void room.disconnect(); // also stops your camera and mic
remote.replaceChildren();
self.replaceChildren();
};
}, [callRoom, userId, userName, video]);
async function toggleMic() {
const me = roomRef.current?.localParticipant;
if (!me) return;
await me.setMicrophoneEnabled(!me.isMicrophoneEnabled);
setMicOn(me.isMicrophoneEnabled);
}
async function toggleCamera() {
const me = roomRef.current?.localParticipant;
if (!me) return;
await me.setCameraEnabled(!me.isCameraEnabled);
setCamOn(me.isCameraEnabled);
}
const label = {
calling: 'Calling…',
connected: 'Connected',
ended: 'Call ended',
error: 'Could not start the call',
}[status];
return (
<section className="call">
<p role="status">{label}</p>
{audioBlocked && (
<button type="button" onClick={() => roomRef.current?.startAudio()}>
Tap to hear the call
</button>
)}
<div ref={remoteRef} className="call-remote" />
<div ref={selfRef} className="call-self" />
<div className="call-controls">
<button type="button" onClick={toggleMic}>{micOn ? 'Mute' : 'Unmute'}</button>
{video && <button type="button" onClick={toggleCamera}>{camOn ? 'Camera off' : 'Camera on'}</button>}
<button type="button" onClick={() => roomRef.current?.disconnect()}>Hang up</button>
</div>
</section>
);
}Flutter: push CallPage with a GravixTokenProvider.endpoint(...) you create once per signed-in user. React: render <VideoCall callRoom="..." userId="..." userName="..." /> inside your signed-in app, and size .call-remote video and .call-self video in your CSS.
More than two people? The same code grows into a meeting: see video meetings and group calls.
Test it
Start your backend
Run it with GRAVIX_APP_ID, GRAVIX_APP_CERTIFICATE and GRAVIX_URL set in its environment.
Start a call
Sign in as one user on a phone or browser and call a second user. Allow the microphone and camera.
Answer on a second device
Sign in as the second user and open the same call. You see and hear each other.
Stuck? Talk to a person, or let your AI coding assistant do the wiring with the AI assistant guide.
