metered_realtime 0.1.0
metered_realtime: ^0.1.0 copied to clipboard
Flutter WebRTC SDK for video & voice calls, screen sharing, and realtime pub/sub messaging — signaling, presence, auto-reconnect, and TURN, over flutter_webrtc.
example/lib/main.dart
// A runnable 1:N video-call demo for the metered_realtime SDK.
//
// Two devices that join the same channel see each other's camera. Supply a
// publishable key at build time:
//
// flutter run --dart-define=METERED_API_KEY=pk_live_xxx
//
// (or a tokenProvider for production). Camera/mic permissions are declared in
// the Android manifest and iOS Info.plist of this example.
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) {
return MaterialApp(
title: 'metered_realtime example',
theme: ThemeData.dark(useMaterial3: true),
home: const CallPage(),
);
}
}
/// Per-remote renderer + its stream subscriptions and lifecycle flags. Owning
/// these together lets us subscribe to the remote's streams BEFORE the async
/// renderer init completes (so an early stream-added isn't dropped — the SDK's
/// streams are broadcast with no replay) and ignore events after teardown.
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 to the remote's stream lifecycle. Call before [markReady] so no
/// event is missed during initialization.
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; // torn down during init
_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; the `is` (not `as`)
// keeps this safe even under a custom factory.
renderer.srcObject =
stream is FlutterWebrtcMediaStream ? stream.native : null;
onChanged?.call();
}
Future<void> dispose() async {
_disposed = true;
for (final s in _subs) {
await s.cancel();
}
_subs.clear();
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> {
// Provide your own pk_live_ key via --dart-define=METERED_API_KEY=… (or a
// tokenProvider for production).
static const String _apiKey = String.fromEnvironment(
'METERED_API_KEY',
defaultValue: 'pk_live_REPLACE_ME',
);
final TextEditingController _channelCtrl =
TextEditingController(text: 'flutter-demo');
MeteredPeer? _peer;
bool _busy = false;
bool _leaving = false;
String? _error;
final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
MediaStream? _localStream;
final Map<String, _RemoteView> _remoteViews = {};
final List<StreamSubscription<dynamic>> _peerSubs = [];
bool get _inCall => _peer != null;
Future<void> _join() async {
if (_busy || _peer != null) return; // guard double-tap / re-entry
setState(() {
_busy = true;
_error = null;
});
try {
await _localRenderer.initialize();
final peer = MeteredPeer(MeteredPeerOptions(apiKey: _apiKey));
_peer = peer;
_peerSubs
..add(peer.onPeerJoined.listen((r) => unawaited(_addRemote(r))))
..add(peer.onPeerLeft.listen((r) => unawaited(_removeRemote(r.id))))
..add(peer.onError.listen((e) => _showError(_friendlyError(e))));
await peer.join(_channelCtrl.text.trim());
// Capture and publish the local camera + microphone.
final stream = await navigator.mediaDevices
.getUserMedia({'audio': true, 'video': true});
_localStream = stream;
_localRenderer.srcObject = stream;
await peer.addStream(wrapMediaStream(stream));
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); // subscribe before the async init below
_remoteViews[remote.id] = view;
await view.markReady();
if (!mounted) {
_remoteViews.remove(remote.id);
await view.dispose();
return;
}
setState(() {});
}
Future<void> _removeRemote(String id) async {
final view = _remoteViews.remove(id);
if (view != null) await view.dispose();
if (mounted) setState(() {});
}
/// Stop + release the local camera/mic. Called first on teardown so the
/// hardware (and its on-air indicator) frees even if the rest is slow.
Future<void> _stopLocalMedia() async {
_localRenderer.srcObject = null;
final local = _localStream;
_localStream = null;
if (local != null) {
for (final track in local.getTracks()) {
await track.stop();
}
await local.dispose();
}
}
Future<void> _leave() async {
if (_leaving) return; // idempotent
_leaving = true;
if (mounted) setState(() => _busy = true);
await _stopLocalMedia();
for (final s in _peerSubs) {
await s.cancel();
}
_peerSubs.clear();
for (final view in _remoteViews.values) {
await view.dispose();
}
_remoteViews.clear();
await _peer?.close();
_peer = null;
_leaving = false;
if (mounted) setState(() => _busy = false);
}
void _showError(String message) {
if (mounted) setState(() => _error = message);
}
/// Map a raw plugin/SDK error to an actionable, non-leaky message.
String _friendlyError(Object e) {
final s = '$e';
if (s.contains('NotAllowed') ||
s.contains('Permission') ||
s.contains('denied')) {
return 'Camera/microphone permission denied — grant access in Settings '
'and try again.';
}
if (s.contains('NotFound') ||
s.contains('NotReadable') ||
s.contains('Could not start')) {
return 'No camera/microphone available (or it is in use by another app).';
}
return 'Could not start the call. $s';
}
@override
void dispose() {
// dispose() can't await — release the camera/mic synchronously so the
// on-air indicator goes off immediately, then finish teardown async.
_localRenderer.srcObject = null;
final local = _localStream;
_localStream = null;
if (local != null) {
for (final track in local.getTracks()) {
track.stop();
}
unawaited(local.dispose());
}
unawaited(_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(
tooltip: 'Leave',
icon: const Icon(Icons.call_end),
onPressed: _busy ? null : _leave,
),
],
),
body: _inCall ? _buildCall() : _buildJoin(),
);
}
Widget _buildJoin() {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('metered_realtime — video call demo',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
TextField(
controller: _channelCtrl,
decoration: const InputDecoration(
labelText: 'Channel',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _busy ? null : _join,
icon: _busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.videocam),
label: Text(_busy ? 'Joining…' : 'Join call'),
),
if (_apiKey == 'pk_live_REPLACE_ME') ...[
const SizedBox(height: 16),
const Text(
'Set a key: flutter run --dart-define=METERED_API_KEY=pk_live_…',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.orangeAccent),
),
],
if (_error != null) ...[
const SizedBox(height: 16),
Text(_error!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.redAccent)),
],
],
),
),
),
);
}
Widget _buildCall() {
final tiles = <Widget>[
_VideoTile(renderer: _localRenderer, label: 'You', mirror: true),
for (final view in _remoteViews.values)
_VideoTile(renderer: view.renderer, label: view.peerId),
];
return Column(
children: [
if (_error != null)
Container(
width: double.infinity,
color: Colors.red.shade900,
padding: const EdgeInsets.all(8),
child: Text(_error!, textAlign: TextAlign.center),
),
Expanded(
child: GridView.count(
crossAxisCount: tiles.length <= 1 ? 1 : 2,
childAspectRatio: 3 / 4,
padding: const EdgeInsets.all(8),
mainAxisSpacing: 8,
crossAxisSpacing: 8,
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) {
return 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),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(6),
),
child: Text(label,
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis),
),
),
],
),
),
);
}
}