Skip to main content

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; a pk_live_ key works too (tick Send — 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.

POST /api/mint-call-token (Node)
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

  1. Not disposing renderers on onPeerLeft — the tile freezes and leaks a native texture. Always dispose() the RTCVideoRenderer.
  2. Not re-binding srcObject on every onStreamAdded — after a reconnect the SDK surfaces a fresh stream object; a renderer holding the old one shows a frozen frame. The _RemoteView._bind above re-assigns every time.
  3. Subscribing to onStreamAdded after await renderer.initialize() — the first frame can arrive during the await and be dropped. Subscribe first (the _RemoteView pattern).
  4. addStream after join without expecting renegotiation — two extra SDP round trips per peer (~200–600 ms). Get media first, addStream, then join when the permission UX allows.
  5. Letting getUserMedia fail silently — if the user denies the camera, your addStream never happens and peers see nothing. Wrap it in try/catch and show an "allow camera" UI.
  6. Forgetting to stop local tracks on closepeer.close() doesn't stop the camera; the capture light stays on. Stop tracks + dispose the stream (the _leave above does it first, so the indicator clears promptly).
  7. Not testing reconnect — users will lose Wi-Fi mid-call. Read Reconnect Best Practices and verify your "reconnecting" UI before shipping.

See also