Example: 1:N Video Call
A complete, runnable Flutter app — a join screen plus a grid of video tiles, one per peer. Everyone who joins the same channel sees everyone else's camera. Below is a condensed version of the app shipped in the package's example/ directory — the shipped copy adds a little more production-hardening (a dedicated video-tile widget, fuller teardown and error handling), but runs identically.
Run it
# Two browser windows — the fastest smoke test, no device setup:
flutter run -d chrome --dart-define=METERED_API_KEY=pk_live_…
# Or two devices / emulators (do Platform Setup first):
flutter run --dart-define=METERED_API_KEY=pk_live_…
Enter the same channel name on both, tap Join, and you've got a call.
pk_ + WebRTC
The pk_live_ key must have Send ticked in the dashboard, or peers connect via presence but no video flows. See the pk_ gotcha.
lib/main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:metered_realtime/metered_realtime.dart';
void main() => runApp(const MeteredRealtimeExampleApp());
class MeteredRealtimeExampleApp extends StatelessWidget {
const MeteredRealtimeExampleApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'metered_realtime example',
theme: ThemeData.dark(useMaterial3: true),
home: const CallPage(),
);
}
/// Per-remote renderer + subscriptions. Subscribing BEFORE the async renderer
/// init means a fast first frame isn't dropped (onStreamAdded is a broadcast
/// stream with no replay).
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;
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);
}
void _bind(MediaStreamLike? stream) {
if (_disposed) return;
if (!_initialized) {
_pending = stream;
return;
}
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();
}
}
class CallPage extends StatefulWidget {
const CallPage({super.key});
@override
State<CallPage> createState() => _CallPageState();
}
class _CallPageState extends State<CallPage> {
static const String _apiKey = String.fromEnvironment(
'METERED_API_KEY',
defaultValue: 'pk_live_REPLACE_ME',
);
final _channelCtrl = TextEditingController(text: 'flutter-demo');
MeteredPeer? _peer;
bool _busy = false;
String? _error;
final _localRenderer = RTCVideoRenderer();
MediaStream? _localStream;
final _remotes = <String, _RemoteView>{};
final _subs = <StreamSubscription<dynamic>>[];
bool get _inCall => _peer != null;
Future<void> _join() async {
if (_busy || _peer != null) return;
setState(() {
_busy = true;
_error = null;
});
try {
await _localRenderer.initialize();
final peer = MeteredPeer(MeteredPeerOptions(apiKey: _apiKey));
_peer = peer;
_subs
..add(peer.onPeerJoined.listen(_addRemote))
..add(peer.onPeerLeft.listen((r) => _removeRemote(r.id)))
..add(peer.onError.listen((e) => _showError('$e')));
await peer.join(_channelCtrl.text.trim());
final stream = await navigator.mediaDevices
.getUserMedia({'audio': true, 'video': true});
_localStream = stream;
_localRenderer.srcObject = stream;
await peer.addStream(wrapMediaStream(stream), metadata: {'role': 'camera'});
if (mounted) setState(() => _busy = false);
} catch (e) {
await _leave();
if (mounted) setState(() {
_busy = false;
_error = _friendlyError(e);
});
}
}
Future<void> _addRemote(RemotePeer remote) async {
final view = _RemoteView(remote.id, onChanged: () {
if (mounted) setState(() {});
});
view.listen(remote);
_remotes[remote.id] = view;
await view.markReady();
if (mounted) setState(() {});
}
Future<void> _removeRemote(String id) async {
await _remotes.remove(id)?.dispose();
if (mounted) setState(() {});
}
Future<void> _leave() async {
for (final s in _subs) {
await s.cancel();
}
_subs.clear();
for (final v in _remotes.values) {
await v.dispose();
}
_remotes.clear();
_localRenderer.srcObject = null;
for (final t in _localStream?.getTracks() ?? const []) {
await t.stop();
}
await _localStream?.dispose();
_localStream = null;
await _peer?.close();
_peer = null;
if (mounted) setState(() {});
}
void _showError(String m) {
if (mounted) setState(() => _error = m);
}
String _friendlyError(Object e) {
final s = '$e';
if (s.contains('NotAllowed') || s.contains('Permission')) {
return 'Camera/microphone permission denied — grant access and retry.';
}
return 'Could not start the call. $s';
}
@override
void dispose() {
_leave().then((_) => _localRenderer.dispose());
_channelCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_inCall ? 'In call: ${_peer?.channel ?? ''}' : 'metered_realtime'),
actions: [
if (_inCall)
IconButton(
icon: const Icon(Icons.call_end),
onPressed: _busy ? null : _leave,
),
],
),
body: _inCall ? _buildCall() : _buildJoin(),
);
}
Widget _buildJoin() => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _channelCtrl,
decoration: const InputDecoration(
labelText: 'Channel', border: OutlineInputBorder()),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _busy ? null : _join,
icon: const Icon(Icons.videocam),
label: Text(_busy ? 'Joining…' : 'Join call'),
),
if (_error != null) ...[
const SizedBox(height: 16),
Text(_error!, style: const TextStyle(color: Colors.redAccent)),
],
],
),
),
);
Widget _buildCall() {
final tiles = <Widget>[
_tile(_localRenderer, 'You', mirror: true),
for (final v in _remotes.values) _tile(v.renderer, v.peerId),
];
return GridView.count(
crossAxisCount: tiles.length <= 1 ? 1 : 2,
childAspectRatio: 3 / 4,
padding: const EdgeInsets.all(8),
mainAxisSpacing: 8,
crossAxisSpacing: 8,
children: tiles,
);
}
Widget _tile(RTCVideoRenderer renderer, String label, {bool mirror = false}) =>
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
RTCVideoView(renderer,
mirror: mirror,
objectFit:
RTCVideoViewObjectFit.RTCVideoViewObjectFitCover),
Positioned(
left: 8,
bottom: 8,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
color: Colors.black54,
child: Text(label,
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis),
),
),
],
),
),
);
}
What it demonstrates
- The full media round trip —
getUserMedia→wrapMediaStream→addStream, and inboundonStreamAdded→.native→RTCVideoRenderer. - The
_RemoteViewpattern — subscribe before the async renderer init so no early frame is dropped, and re-bindsrcObjecton every event (reconnect-safe). - Clean teardown — stop local tracks first (camera light off), dispose every renderer, then
close(). - Permission-aware errors — a denied camera surfaces a readable message rather than a black screen.
For production, layer on the reconnect banner, mute/screen-share controls (WebRTC Video Call → Step 5), and a tokenProvider instead of the publishable key.
See also
- WebRTC Video Call guide — the same call, broken down step by step
- Platform Setup — permissions before you run it
MeteredPeerreference