What you build
A live stream is a room with two kinds of people in it.
- The host joins with a token that may publish, then turns on the camera and microphone.
- The audience joins the same room with a watch-only token. They see and hear the host.
Use one room name per stream, for example the stream's ID in your app. Your server decides who is the host when it signs the token. The app only asks.
Prerequisites
- A Gravix Cloud account. Sign up free to get your App ID, App Certificate, region URL and 10,000 free minutes.
- A backend where the App Certificate can live. It signs the tokens (step 3).
- Flutter: Flutter 3.38+ and Dart 3.10+. A real Android 7.0+ or iOS 13+ device for the camera.
- React: React 18 or later, a browser with WebRTC, and a page served over HTTPS (a local development server on your own machine also works).
1. Install the SDK
Flutter apps use gravix_rtc from pub.dev. Web and React apps use gravix-rtc from npm.
flutter pub add gravix_rtcnpm install gravix-rtcyarn add gravix-rtcpnpm add gravix-rtcgravix-rtc includes TypeScript types. It also has an optional gravix-rtc/reactentry with hooks for large rooms. This guide does not need it: plain React with useEffect is enough.
2. Permissions
The host needs the camera and the microphone. On Flutter, declare both for each platform you ship, and ask for them at runtime before the host goes live. Viewers only listen, so they need no capture permission.
<!-- 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>In the browser there is nothing to declare. The browser asks the host the first time the camera turns on, and only on HTTPS pages. More platform detail, such as background audio on Android, is on the SDKs page.
3. Get a token from your server
Every join needs an access token. Your server signs it with your App ID and App Certificate, using thegravix-rtc-token library. The App Certificate never goes into the app. If you have not built the endpoint yet, start with your token endpoint.
For a live stream, make one change: the server gives a publishing token only to the host.
// server.ts (your backend). Same endpoint as in the docs, with one change:
// the server decides who is the host. The app only asks.
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 rule, for example "the user who created this stream".
const isHost = await userOwnsStream(req.user.id, room);
const token = await createToken(APP_ID, APP_CERTIFICATE, {
room,
identity: req.user.id,
name: req.user.name,
canPublish: isHost, // viewers get a watch-only token
ttlSeconds: 60 * 60,
});
res.json({ token, url: REGION_URL });
});The SDK posts { room, identity, name, can_publish } to this endpoint and expects{ token, url } back, where url is your region URL.
4. Create the room
Create a token provider that points at your endpoint, and one room object for the live 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 live 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 live screen.
const room = new Room();5. Host: go live
The host joins the room, then publishes the camera and the microphone. Show the host their own camera so they can see what viewers see.
// Host: join, then publish the camera and the microphone.
final ok = await room.connectWithTokenProvider(
tokenProvider: tokens,
request: GravixTokenRequest(
room: streamId,
identity: userId,
name: userName,
canPublish: true, // a request; your server decides
),
publishMic: true,
enableVideo: true,
);
if (!ok) {
// room.lastTokenError?.message says why, when your token endpoint failed
}
// Your own preview. isCameraEnabled turns true once the camera is published.
ValueListenableBuilder<bool>(
valueListenable: room.isCameraEnabled,
builder: (context, on, _) {
final track = room.localVideoTrack;
return on && track != null
? VideoTrackRenderer(track, fit: VideoViewFit.cover)
: const Center(child: Text('Starting camera…'));
},
);import { connectWithTokenProvider } from 'gravix-rtc';
// Host: join, then publish the camera and the microphone.
await connectWithTokenProvider(room, {
tokenProvider: tokens,
request: { room: streamId, identity: userId, name: userName, canPublish: true },
});
const camera = await room.localParticipant.setCameraEnabled(true);
await room.localParticipant.setMicrophoneEnabled(true);
// Your own preview: attach() returns a <video> element.
const preview = camera?.videoTrack?.attach();
if (preview) document.getElementById('stage')!.appendChild(preview);- Flutter:
publishMicandenableVideoturn the microphone and camera on as part of the join. Useroom.setMicEnabled()androom.switchCamera()for the host's controls. - Web:
setCameraEnabled(true)asks the browser for the camera and publishes it. - If viewers do not see the host, check that your server signed a publishing token (
canPublish: true) for that user.
6. Audience: watch the stream
Viewers join the same room without publishing. The host's camera arrives as a remote video track, and the host's audio plays on its own.
// Audience: join without publishing and show the host's camera.
VideoTrack? hostVideo;
room.onRemoteVideoTrack = (uid, track) => setState(() => hostVideo = track);
room.onRemoteVideoTrackRemoved = (uid) => setState(() => hostVideo = null);
await room.connectWithTokenProvider(
tokenProvider: tokens,
request: GravixTokenRequest(room: streamId, identity: userId, name: userName),
publishMic: false,
enableVideo: false,
);
// The host's audio plays on its own. Show the video with:
// hostVideo == null ? const Text('Waiting for the host…') : VideoTrackRenderer(hostVideo!, fit: VideoViewFit.cover)import { RoomEvent, connectWithTokenProvider } from 'gravix-rtc';
const stage = document.getElementById('stage')!;
// Audience: the host's camera and mic arrive as subscribed tracks.
room
.on(RoomEvent.TrackSubscribed, (track) => {
stage.appendChild(track.attach()); // a <video> or <audio> element
})
.on(RoomEvent.TrackUnsubscribed, (track) => {
track.detach().forEach((el) => el.remove());
})
// Browsers can block sound until the user clicks. Show a button, then call room.startAudio().
.on(RoomEvent.AudioPlaybackStatusChanged, () => {
showUnmuteButton(!room.canPlaybackAudio);
});
await connectWithTokenProvider(room, {
tokenProvider: tokens,
request: { room: streamId, identity: userId, name: userName }, // canPublish defaults to false
});Browsers can block sound on a page the user has not clicked yet. When room.canPlaybackAudio is false, show a button that calls room.startAudio().
7. Leave the stream
Leave when the screen closes. For the host, this ends the camera and microphone too.
// Leave the stream. A host leaving also stops the camera and mic.
await room.disconnect();
// When the screen goes away for good.
await room.dispose();// Leave the stream. This also stops the host's camera and mic.
await room.disconnect();Full sample
One screen for both roles. Pass isHost from your app, for example true for the user who created the stream. Your server still has the final word through the token.
import 'package:flutter/material.dart';
import 'package:gravix_rtc/gravix_rtc.dart';
/// One screen for both roles. isHost comes from your app;
/// the token from your server decides whether this user may really publish.
class LivePage extends StatefulWidget {
const LivePage({
super.key,
required this.tokens,
required this.streamId,
required this.userId,
required this.userName,
required this.isHost,
});
final GravixTokenProvider tokens;
final String streamId;
final String userId;
final String userName;
final bool isHost;
@override
State<LivePage> createState() => _LivePageState();
}
class _LivePageState extends State<LivePage> {
final room = GravixRoomService();
VideoTrack? hostVideo; // what the audience watches
String? error;
@override
void initState() {
super.initState();
room.onRemoteVideoTrack = (uid, track) {
if (mounted) setState(() => hostVideo = track);
};
room.onRemoteVideoTrackRemoved = (uid) {
if (mounted) setState(() => hostVideo = null);
};
_join();
}
Future<void> _join() async {
final ok = await room.connectWithTokenProvider(
tokenProvider: widget.tokens,
request: GravixTokenRequest(
room: widget.streamId,
identity: widget.userId,
name: widget.userName,
canPublish: widget.isHost,
),
publishMic: widget.isHost,
enableVideo: widget.isHost,
);
if (!ok && mounted) {
setState(() => error = room.lastTokenError?.message ?? 'Could not join the stream');
}
}
@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: widget.isHost ? _preview() : _hostVideo()),
if (widget.isHost)
Positioned(left: 0, right: 0, bottom: 32, child: _hostControls()),
],
);
}
// Host: your own camera.
Widget _preview() => ValueListenableBuilder<bool>(
valueListenable: room.isCameraEnabled,
builder: (context, on, _) {
final track = room.localVideoTrack;
if (!on || track == null) return const Center(child: Text('Starting camera…'));
return VideoTrackRenderer(track, fit: VideoViewFit.cover);
},
);
// Audience: the host's camera. The host's audio plays on its own.
Widget _hostVideo() {
final track = hostVideo;
if (track == null) return const Center(child: Text('Waiting for the host…'));
return VideoTrackRenderer(track, fit: VideoViewFit.cover);
}
Widget _hostControls() => 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),
IconButton.filled(
tooltip: 'Flip camera',
icon: const Icon(Icons.cameraswitch),
onPressed: room.switchCamera,
),
const SizedBox(width: 16),
IconButton.filled(
tooltip: 'End live',
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
// and caches each token until shortly before it expires.
const tokens = GravixTokenProvider.endpoint('https://YOUR-BACKEND/rtc/token', {
credentials: 'include',
});
type Props = { streamId: string; userId: string; userName: string; isHost: boolean };
type Status = 'joining' | 'live' | 'ended' | 'error';
export function LiveStream({ streamId, userId, userName, isHost }: Props) {
const stageRef = useRef<HTMLDivElement>(null);
const roomRef = useRef<Room | null>(null);
const [status, setStatus] = useState<Status>('joining');
const [audioBlocked, setAudioBlocked] = useState(false);
useEffect(() => {
const stage = stageRef.current!;
const room = new Room();
roomRef.current = room;
let cancelled = false;
room
// Audience: the host's camera and mic arrive as subscribed tracks.
.on(RoomEvent.TrackSubscribed, (track) => {
stage.appendChild(track.attach()); // a <video> or <audio> element
})
.on(RoomEvent.TrackUnsubscribed, (track) => {
track.detach().forEach((el) => el.remove());
})
// Browsers can block sound until the user clicks something.
.on(RoomEvent.AudioPlaybackStatusChanged, () => {
setAudioBlocked(!room.canPlaybackAudio);
})
.on(RoomEvent.Disconnected, () => {
if (!cancelled) setStatus('ended');
});
(async () => {
try {
await connectWithTokenProvider(room, {
tokenProvider: tokens,
request: { room: streamId, identity: userId, name: userName, canPublish: isHost },
});
if (cancelled) return;
if (isHost) {
// Host: publish camera and mic, and show your own preview.
const camera = await room.localParticipant.setCameraEnabled(true);
await room.localParticipant.setMicrophoneEnabled(true);
const preview = camera?.videoTrack?.attach();
if (preview) stage.appendChild(preview);
}
if (!cancelled) setStatus('live');
} catch {
if (!cancelled) setStatus('error');
}
})();
return () => {
cancelled = true;
roomRef.current = null;
void room.disconnect(); // also stops the host's camera and mic
stage.replaceChildren();
};
}, [streamId, userId, userName, isHost]);
const label = {
joining: 'Joining…',
live: isHost ? 'You are live' : 'Watching',
ended: 'The stream has ended',
error: 'Could not join the stream',
}[status];
return (
<section>
<p role="status">{label}</p>
{audioBlocked && (
<button type="button" onClick={() => roomRef.current?.startAudio()}>
Tap to hear the stream
</button>
)}
<div ref={stageRef} className="stage" />
</section>
);
}Flutter: push LivePage with a GravixTokenProvider.endpoint(...) you create once per signed-in user. React: render <LiveStream streamId="..." userId="..." userName="..." isHost={false} /> inside your signed-in app, and give .stage video a size in your CSS.
Test it
Start your token endpoint
Run your backend with GRAVIX_APP_ID, GRAVIX_APP_CERTIFICATE and GRAVIX_URL set in its environment.
Go live on one device
Open the live screen as the host. Allow the camera and microphone. You see your own preview.
Watch on another device
Open the same stream ID as a viewer, on a second phone or browser. You see and hear the host.
Stuck? Talk to a person, or let your AI coding assistant do the wiring with theAI assistant guide.
