metered_realtime 0.1.0 copy "metered_realtime: ^0.1.0" to clipboard
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),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
0
likes
160
points
105
downloads

Documentation

API reference

Publisher

verified publishermetered.ca

Weekly Downloads

Flutter WebRTC SDK for video & voice calls, screen sharing, and realtime pub/sub messaging — signaling, presence, auto-reconnect, and TURN, over flutter_webrtc.

Homepage

Topics

#webrtc #video-call #signaling #realtime #turn

License

MIT (license)

Dependencies

flutter, flutter_webrtc, web_socket_channel

More

Packages that depend on metered_realtime