WebRTC Video Call
Build a multi-party video call in Flutter with MeteredPeer + flutter_webrtc. The SDK handles SDP exchange, ICE trickle, perfect negotiation, peer discovery via presence, and media fan-out. You write the rendering and the widget glue.
By the end: two devices (or two flutter run -d chrome windows) join the same channel and see each other's camera. Add more → automatic mesh.
Prerequisites
- Platform Setup done — camera/mic permissions per platform.
- A key from the dashboard. This guide uses the
tokenProvider(JWT) path; apk_live_key works too (tickSend— see Authentication). - An active TURN service on your app (any tier). Auto-injected into the welcome when your
sk_key has "Auto-inject TURN" on (default) — you don't embed creds yourself.
Step 1 — The renderer holder
flutter_webrtc renders video through an RTCVideoRenderer (one per stream you display) shown in an RTCVideoView. Initialising a renderer is async, and the SDK's onStreamAdded is a broadcast stream with no replay — so subscribe before the await initialize() completes, or a fast-arriving first frame is dropped. This small holder does that:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:metered_realtime/metered_realtime.dart';
class _RemoteView {
_RemoteView(this.peerId, {this.onChanged});
final String peerId;
final VoidCallback? onChanged;
final RTCVideoRenderer renderer = RTCVideoRenderer();
final List<StreamSubscription<dynamic>> _subs = [];
bool _initialized = false;
bool _disposed = false;
MediaStreamLike? _pending;
/// Subscribe BEFORE markReady() so no early stream event is missed.
void listen(RemotePeer remote) {
_subs
..add(remote.onStreamAdded.listen((ev) => _bind(ev.stream)))
..add(remote.onStreamRemoved.listen((_) => _bind(null)));
}
Future<void> markReady() async {
await renderer.initialize();
if (_disposed) return;
_initialized = true;
if (_pending != null) _bind(_pending); // apply a stream that arrived early
}
void _bind(MediaStreamLike? stream) {
if (_disposed) return;
if (!_initialized) {
_pending = stream; // re-applied once the renderer is ready
return;
}
// Inbound streams are always flutter_webrtc-backed.
renderer.srcObject =
stream is FlutterWebrtcMediaStream ? stream.native : null;
onChanged?.call();
}
Future<void> dispose() async {
_disposed = true;
for (final s in _subs) {
await s.cancel();
}
if (_initialized) renderer.srcObject = null;
await renderer.dispose();
}
}
Step 2 — The call screen
class CallPage extends StatefulWidget {
const CallPage({super.key, required this.channel});
final String channel;
@override
State<CallPage> createState() => _CallPageState();
}
class _CallPageState extends State<CallPage> {
MeteredPeer? _peer;
final _localRenderer = RTCVideoRenderer();
MediaStream? _localStream;
final _remotes = <String, _RemoteView>{};
final _subs = <StreamSubscription<dynamic>>[];
@override
void initState() {
super.initState();
_join();
}
Future<void> _join() async {
await _localRenderer.initialize();
final peer = MeteredPeer(MeteredPeerOptions(
tokenProvider: () async {
final r = await http.get(Uri.parse('https://your.app/api/mint-call-token'));
return (jsonDecode(r.body) as Map)['token'] as String;
},
));
_peer = peer;
_subs
..add(peer.onPeerJoined.listen(_addRemote))
..add(peer.onPeerLeft.listen((r) => _removeRemote(r.id)))
..add(peer.onError.listen((e) => debugPrint('call error: $e')));
await peer.join(widget.channel);
// Capture and publish the local camera + mic.
final stream = await navigator.mediaDevices
.getUserMedia({'audio': true, 'video': true});
_localStream = stream;
_localRenderer.srcObject = stream;
await peer.addStream(wrapMediaStream(stream), metadata: {'role': 'camera'});
setState(() {});
}
Future<void> _addRemote(RemotePeer remote) async {
final view = _RemoteView(remote.id, onChanged: () {
if (mounted) setState(() {});
});
view.listen(remote); // subscribe first
_remotes[remote.id] = view;
await view.markReady(); // then init the renderer
if (mounted) setState(() {});
}
Future<void> _removeRemote(String id) async {
await _remotes.remove(id)?.dispose();
if (mounted) setState(() {});
}
@override
void dispose() {
_leave();
super.dispose();
}
Future<void> _leave() async {
for (final s in _subs) {
await s.cancel();
}
for (final v in _remotes.values) {
await v.dispose();
}
_remotes.clear();
// Release the camera FIRST so the capture indicator turns off promptly.
_localRenderer.srcObject = null;
for (final t in _localStream?.getTracks() ?? const []) {
await t.stop();
}
await _localStream?.dispose();
await _peer?.close();
await _localRenderer.dispose();
}
@override
Widget build(BuildContext context) {
final tiles = <Widget>[
_VideoTile(renderer: _localRenderer, label: 'You', mirror: true),
for (final v in _remotes.values)
_VideoTile(renderer: v.renderer, label: v.peerId),
];
return Scaffold(
appBar: AppBar(title: Text('Call: ${widget.channel}')),
body: GridView.count(
crossAxisCount: tiles.length <= 1 ? 1 : 2,
children: tiles,
),
);
}
}
class _VideoTile extends StatelessWidget {
const _VideoTile({required this.renderer, required this.label, this.mirror = false});
final RTCVideoRenderer renderer;
final String label;
final bool mirror;
@override
Widget build(BuildContext context) => Stack(
fit: StackFit.expand,
children: [
RTCVideoView(renderer,
mirror: mirror,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover),
Positioned(left: 8, bottom: 8, child: Text(label)),
],
);
}
That's a working call. Two devices on widget.channel → mutual video; three → three-way mesh. Each peer connects directly to each other peer; TURN relays only when direct connect fails.
Step 3 — Mint the JWT (backend)
The tokenProvider above hits your backend, which signs an HS256 JWT. No TURN credentials needed — the Realtime service auto-injects them when your sk_ key has "Auto-inject TURN" enabled.
const jwt = require("jsonwebtoken");
app.get("/api/mint-call-token", requireAuth, (req, res) => {
const token = jwt.sign(
{
sub: req.user.id,
channels: [`app_${req.appId}/call-*`],
permissions: ["publish", "subscribe", "presence", "send"], // `send` is required for WebRTC
peerMetadata: { username: req.user.name, avatarUrl: req.user.avatar },
exp: Math.floor(Date.now() / 1000) + 3600,
},
process.env.SK_SECRET,
{ algorithm: "HS256", header: { alg: "HS256", kid: process.env.SK_ID } },
);
res.json({ token });
});
Or skip the signing and call POST /v1/tokens. See Authentication for the full walkthrough.
Step 4 — Chat / control messages
Same channel, server-routed via peer.send:
peer.onData.listen((m) {
final data = m.data;
if (data is Map && data['type'] == 'chat') {
appendChatMessage(m.senderPeerId, data['text']);
}
});
await peer.send({'type': 'chat', 'text': controller.text});
peer.send is server-routed (works before ICE completes, no DataChannel to manage) — ideal for chat. See the routing trade-offs.
Step 5 — Mute, camera-off, screen share
Toggle audio / video cheaply
void setAudioEnabled(bool enabled) {
for (final t in _localStream!.getAudioTracks()) {
t.enabled = enabled;
}
_peer!.send({'type': 'audio-state', 'enabled': enabled}); // tell peers
}
track.enabled = false keeps the sender wired up (peers still see the track, just silent) — cheaper than removing and re-adding.
Screen share — two patterns
Pattern A — simultaneous camera + screen (preferred): add the screen as a SECOND stream. Receivers branch on metadata['role'] and lay them out side-by-side.
final screen = await navigator.mediaDevices.getDisplayMedia({'video': true});
_screenStream = screen;
await _peer!.addStream(wrapMediaStream(screen), metadata: {'role': 'screen'});
// ...later:
await _peer!.removeStream(wrapMediaStream(screen));
for (final t in screen.getTracks()) { await t.stop(); }
remote.onStreamAdded.listen((ev) {
if (ev.metadata?['role'] == 'camera') attachToFaceTile(ev.stream);
if (ev.metadata?['role'] == 'screen') attachToScreenTile(ev.stream);
});
Pattern B — in-place swap (one tile per peer): swap the video track without renegotiating.
final screen = await navigator.mediaDevices.getDisplayMedia({'video': true});
try {
await _peer!.replaceTrack(
wrapMediaStreamTrack(_localStream!.getVideoTracks().first),
wrapMediaStreamTrack(screen.getVideoTracks().first),
);
} on MeteredPeerReplaceTrackError catch (e) {
for (final f in e.failed) {
debugPrint('screen share failed for ${f.peerId}: ${f.err}');
}
}
replaceTrack is much faster than removeStream + addStream, and a partial failure leaves the call working (e.failed lists who to retry). Most modern UIs use Pattern A.
Pitfalls
- Not disposing renderers on
onPeerLeft— the tile freezes and leaks a native texture. Alwaysdispose()theRTCVideoRenderer. - Not re-binding
srcObjecton everyonStreamAdded— after a reconnect the SDK surfaces a fresh stream object; a renderer holding the old one shows a frozen frame. The_RemoteView._bindabove re-assigns every time. - Subscribing to
onStreamAddedafterawait renderer.initialize()— the first frame can arrive during the await and be dropped. Subscribe first (the_RemoteViewpattern). addStreamafterjoinwithout expecting renegotiation — two extra SDP round trips per peer (~200–600 ms). Get media first,addStream, thenjoinwhen the permission UX allows.- Letting
getUserMediafail silently — if the user denies the camera, youraddStreamnever happens and peers see nothing. Wrap it in try/catch and show an "allow camera" UI. - Forgetting to stop local tracks on close —
peer.close()doesn't stop the camera; the capture light stays on. Stop tracks + dispose the stream (the_leaveabove does it first, so the indicator clears promptly). - Not testing reconnect — users will lose Wi-Fi mid-call. Read Reconnect Best Practices and verify your "reconnecting" UI before shipping.
See also
- Example: Video Call — the complete runnable app
- Reconnect Best Practices — required production reading
MeteredPeerreference ·RemotePeerreference- Authentication — full JWT walkthrough
- Platform Setup — per-platform permissions