MeteredPeer
The high-level class. One MeteredPeer instance = one signalling connection + one channel + N RTCPeerConnections (one per remote peer in the channel).
Use this for WebRTC video calls, screen share, peer-to-peer games, multiplayer presence + chat. If you only need pub/sub (no media, no per-peer state), SignallingClient is smaller and simpler.
Construct
import 'package:metered_realtime/metered_realtime.dart';
final peer = MeteredPeer(MeteredPeerOptions(
apiKey: 'pk_live_…',
// — OR —
tokenProvider: () async => fetchJwtFromYourBackend(),
));
Provide exactly one of apiKey or tokenProvider. The constructor doesn't connect — call join() for that.
MeteredPeerOptions
| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | String? | — | pk_live_…. Mutually exclusive with tokenProvider. |
tokenProvider | TokenProvider? (Future<String> Function()) | — | Returns an HS256 JWT. Called on first connect AND every reconnect (auto-refresh). |
url | String? | wss://rms.metered.ca | Server origin. The SDK appends /v1. Validated at construction (must be wss:, or ws: for localhost; no path/query/fragment/userinfo). |
logger | Logger? | NoopLogger | Use ConsoleLogger() during development, your own Logger in prod. |
autoReconnect | bool | true | Master switch for auto-reconnect. |
reconnect | ReconnectOptions | const ReconnectOptions() | Backoff knobs (ignored when autoReconnect: false). See SignallingClient. |
inactivityTimeoutMs | int | 60000 | If no frame arrives in this window, the SDK closes-and-reconnects. 0 disables the watchdog + keepalive. |
tokenProviderTimeoutMs | int | 10000 | Cap on how long the SDK waits for tokenProvider() to resolve. Must be > 0. |
autoResubscribe | bool | true | After reconnect, re-subscribe to the active channel. Leave true for MeteredPeer — its channel recovery depends on it. |
rtcPeerConnectionFactory | RtcPeerConnectionFactory? | the flutter_webrtc binding | Test injection. Omit in production — the default flutterWebrtcFactory is used. |
webSocketFactory | WebSocketFactory? | the web_socket_channel adapter | Test injection. |
MeteredPeerOptions extends SignallingClientOptions — every option documented there applies here too.
Dart has no T | false union, so the JS reconnect: ReconnectOptions | false becomes an autoReconnect bool plus a reconnect knobs object. And events are Dart Streams, not an EventEmitter — see Streams below.
Methods
All media + lifecycle methods return a Future. await them (or handle the rejection) so errors surface.
join(channel, [opts]) → Future<void>
Connects (if not connected) and subscribes to channel. Completes when the server acks the subscribe.
await peer.join('room-42');
print(peer.peerId); // server-assigned, or your JWT's `sub` claim
Peers don't arrive synchronously with join. The server's initial presence for the channel arrives after the subscribe ack; the SDK then fires onPeerJoined for each existing peer. By the time await peer.join(...) returns, peer.remotePeers is typically still empty — populate your UI from the onPeerJoined handler, not from a snapshot taken right after join.
peer.onPeerJoined.listen(addTile);
await peer.join('room-42');
// peer.remotePeers is likely [] here; existing peers arrive as onPeerJoined
// events over the next ~50–200 ms.
JoinOptions:
| Field | Type | Default | Notes |
|---|---|---|---|
includeSenderMetadata | bool | false | Opt in to receiving each broadcast sender's peerMetadata on onData. |
await peer.join('room-42', const JoinOptions(includeSenderMetadata: true));
Throws / rejects with:
MeteredPeerSendError(SendErrorCode.reservedChannel, …)ifchannelstarts with_metered/,_internal/, or_system/(server-reserved).MeteredPeerStateError(StateErrorCode.invalidState, 'join', …)if the state isn'tidle(already joining/joined, or closed) — construct a newMeteredPeerif you'veclose()d.
If the signalling socket drops and reconnects, the SDK re-subscribes for you. You don't call join() again.
close([reason]) → Future<void>
Closes every RemotePeer, closes the WebSocket, transitions to closed. Terminal — once closed, the same instance can't rejoin. Construct a fresh MeteredPeer.
await peer.close('user logged out');
peer = MeteredPeer(opts);
await peer.join(channel);
Idempotent. close() does not stop your local camera/mic tracks — call track.stop() yourself so the device's capture indicator turns off:
for (final t in localStream.getTracks()) {
await t.stop();
}
await localStream.dispose();
await peer.close();
send(data) → Future<void>
Broadcasts data to every peer in the joined channel. Server-routed via the wire-protocol publish frame. data must be JSON-serialisable.
await peer.send({'type': 'chat', 'text': 'hi everyone'});
sendTo(peerId, data) → Future<void>
Sends data directly to one peer in the channel. Server-routed via the wire-protocol send frame.
await peer.sendTo(otherPeer.id, {'whisper': 'private'});
send and sendTo are distinct methods (not an overload) so routing intent is loud at the call site.
Routing trade-offs (applies to both)
Both are server-routed, not P2P:
Server-routed (send / sendTo) | P2P DataChannel | |
|---|---|---|
| Works before ICE completes | Yes | No — wait for connected |
| Counts against signalling quota | Yes | No |
| Latency | Server hop (~10–50 ms) | P2P (~5–30 ms typical) |
| Survives WebRTC failure | Yes (signalling + media are separate) | No |
| Max payload | maxMessageSize (server default 64 KB) | Transport caps |
For low-latency P2P data, see Data Channels & Low Latency.
Both reject with:
MeteredPeerSendError(SendErrorCode.notJoined, …)if called beforejoin()completes.MeteredPeerSendError(SendErrorCode.selfSend, …)(fromsendTo) ifpeerId == peer.peerId.MeteredPeerSendError(SendErrorCode.invalidArgs, …)ifpeerIdis empty.MeteredPeerOversizedErrorif the JSON-serialized payload exceedsmaxMessageSize. Branch on type:
try {
await peer.send(huge);
} on MeteredPeerOversizedError catch (e) {
showToast('Message too big (${e.size} bytes, max ${e.cap})');
}
addStream(stream, {metadata}) → Future<void>
Attaches every track in stream to every current peer and every peer that joins later. Sugar over addTrack(t, stream: stream, metadata: …) per track.
Pass a flutter_webrtc MediaStream through wrapMediaStream(...):
final local = await navigator.mediaDevices.getUserMedia({'video': true, 'audio': true});
await peer.addStream(
wrapMediaStream(local),
metadata: {'role': 'camera', 'label': 'front cam'},
);
The optional metadata (StreamMetadata) rides over the signalling channel and arrives on the receiver's onStreamAdded + onTrack events — use it to label streams (camera vs screen vs file).
Attach before join() when you can. Streams added before join() ride along in the first SDP offer (one round trip). Streams added after join() trigger a renegotiation per peer (two extra round trips). Add after join() only when the camera-permission UX makes sense at that point.
Reconnect note: the SDK remembers what you added (tracks + metadata). On reconcile, both your media AND its metadata reattach to each survivor's new RTCPeerConnection automatically — you don't re-addStream.
addTrack(track, {stream, metadata}) → Future<void>
Lower-level primitive that addStream sugars over. Use directly for fine-grained control — adding an unaffiliated track, or labelling individual tracks differently.
final tracks = local.getTracks();
final wrapped = wrapMediaStream(local);
await peer.addTrack(wrapMediaStreamTrack(tracks[0]),
stream: wrapped, metadata: {'role': 'voice'});
Omit stream for an unaffiliated track — the receiver gets it on onTrack but no onStreamAdded fires (no stream to surface).
Idempotent on the same track id — calling twice with the same (track, stream) is a no-op. To re-tag, removeTrack(t) then addTrack(t, …) with new metadata.
State gates:
leaving/closed→ throwsMeteredPeerStateError(StateErrorCode.invalidState, 'addTrack', …, currentState). Construct a fresh peer.reconnecting→ tracked but fan-out deferred until the reconcile completes. No error.idle/joining→ tracked, fans out to every peer that joins.
Track sharing across streams is rejected: adding the same track id with a different stream throws MeteredPeerStateError(StateErrorCode.trackAlreadyAttached, 'addTrack', …). Multi-stream membership isn't supported yet — fail loudly rather than silently dropping one association.
A track with a null id can't be tracked and throws MeteredPeerStateError(StateErrorCode.invalidState, …). Real flutter_webrtc tracks always have ids.
Metadata size: if the StreamMetadata bag would exceed maxMessageSize when serialized, addTrack throws MeteredPeerOversizedError before tracking it (so over-cap metadata can't loop through reconcile re-sends). Keep metadata small.
removeStream(stream) → Future<void>
Detaches the stream's tracks from every peer and stops tracking them for future joins. Sugar over removeTrack(t) per track. No-op if you never added it. Doesn't stop the underlying tracks — call track.stop() for that.
removeTrack(track) → Future<void>
Lower-level counterpart to addTrack. Detaches one track from every peer and stops tracking it. No-op if not tracked.
Matching is by stable track id, not object identity — so a track re-wrapped from stream.getTracks() still matches (flutter_webrtc returns a fresh wrapper on every read).
replaceTrack(oldTrack, newTrack) → Future<void>
Swap a track without renegotiating SDP (camera → screen share, mic → file). Fans the swap out to every peer.
final screen = await navigator.mediaDevices.getDisplayMedia({'video': true});
await peer.replaceTrack(
wrapMediaStreamTrack(cameraTrack),
wrapMediaStreamTrack(screen.getVideoTracks().first),
);
newTrack may be null to mute that sender across all peers. oldTrack must have an id; a newTrack without an id is rejected with ArgumentError (pass null to silence instead).
Tracking-map sync: the SDK re-keys its internal tracking under newTrack, so future newcomers + reconcile cycles use it — without this the camera↔screen swap would silently revert on any blip. Metadata is re-keyed under the new id and re-sent. replaceTrack(old, null) drops the entry entirely.
Partial-failure handling: if the swap succeeds on some peers and fails on others, the Future rejects with MeteredPeerReplaceTrackError:
try {
await peer.replaceTrack(oldCam, newCam);
} on MeteredPeerReplaceTrackError catch (e) {
// e.succeeded: List<String> peerIds already on newCam
// e.failed: List<ReplaceTrackFailure> { peerId, err } still on oldCam
for (final f in e.failed) {
print('replaceTrack failed for ${f.peerId}: ${f.err}');
}
}
Throws MeteredPeerStateError synchronously if called during reconnecting, leaving, or closed. Wait for state == MeteredPeerState.joined.
getStreamMetadata(stream) / getTrackMetadata(track) → StreamMetadata?
Local lookups — return the metadata you attached for one of your local streams/tracks (or null if not added / added without metadata). Useful for round-tripping your own labels in the UI.
final meta = peer.getStreamMetadata(localScreen);
print(meta?['label']); // 'shared screen'
To read a remote peer's metadata, use the metadata field on the onStreamAdded / onTrack event, or remote.metadata for peer-level metadata.
StreamMetadata
typedef StreamMetadata = Map<String, Object?>;
The bag you pass to addStream / addTrack, surfaced to the receiver on onStreamAdded + onTrack. Convention only — the SDK doesn't validate keys. The conventional ones:
role—"camera"|"screen"|"canvas"|"file"| customlabel— human-readable, e.g."front cam"
It's server-routed payload (sent as a direct message under a reserved key) and counts against maxMessageSize. Keep it small.
Trust model
StreamMetadata is sender-stamped and server-routed verbatim — NOT JWT-signed. A peer in your channel can put whatever it wants in the bag, and your onTrack / onStreamAdded handlers will see those exact values. Use it for UI-layout hints (camera vs screen, labels), never for authorization. For server-verified identity use the JWT's peerMetadata (surfaces as senderMetadata on onData + remote.metadata).
The receiver caps each remote peer's track-metadata cache at 512 entries (FIFO eviction on overflow), bounding memory if an adversarial peer floods unique track ids.
Per-track metadata travels over the signalling channel under the reserved key __meteredTrackMeta. The SDK intercepts it and never surfaces it on onData. If you use that exact key in a sendTo payload, the SDK warns via your logger and the receiver interprets it as metadata — pick a different key.
Simultaneous camera + screen share — the canonical pattern
final cam = await navigator.mediaDevices.getUserMedia({'video': true, 'audio': true});
await peer.addStream(wrapMediaStream(cam), metadata: {'role': 'camera'});
final screen = await navigator.mediaDevices.getDisplayMedia({'video': true});
await peer.addStream(wrapMediaStream(screen), metadata: {'role': 'screen'});
// Receiver:
peer.onPeerJoined.listen((remote) {
remote.onStreamAdded.listen((ev) {
if (ev.metadata?['role'] == 'camera') attachToFaceTile(ev.stream);
if (ev.metadata?['role'] == 'screen') attachToScreenTile(ev.stream);
});
});
Peers see them as two separate streams and lay them out accordingly — rather than swapping the camera track when switching to screen share, you send both.
Read-only state
peer.state // MeteredPeerState
peer.peerId // String? (null until welcome arrives)
peer.channel // String? (null until joined)
peer.remotePeers // List<RemotePeer> (an unmodifiable snapshot)
MeteredPeerState
enum MeteredPeerState { idle, joining, joined, reconnecting, leaving, closed }
join() close()
idle ────────► joining ──► joined ──────────────────────► leaving ──► closed
│ ▲
transient drop │ │ reconnect succeeded
▼ │
reconnecting
│ terminal close code
▼
closed
| State | Show your user | Send / receive? |
|---|---|---|
idle | — | No |
joining | "connecting…" | No |
joined | normal UI | Yes |
reconnecting | "reconnecting…" banner | Resumes on success |
leaving | "disconnecting…" | No |
closed | "disconnected" + retry | No (terminal; new peer) |
Streams
Every event is a broadcast Stream getter. .listen() returns a StreamSubscription — cancel it when your widget is disposed.
| Stream | Emits | Fires |
|---|---|---|
onJoined | JoinedEvent { peerId, channel } | Once, after join() completes. |
onLeft | LeftEvent { peerId?, channel?, reason? } | Once, when you reach closed. Fields are snapshots of what your peer used (both null if close() fired before welcome / subscribe-ack). |
stateChanges | StateChange<MeteredPeerState> { from, to } | Every state transition. Drives your reconnect UI. |
onPeerJoined | RemotePeer | Another peer joined. Wire up onTrack / onStreamAdded / stateChanges on the remote here. |
onPeerLeft | RemotePeer | Peer unsubscribed or disconnected. Dispose their RTCVideoRenderer here. |
onData | MeteredData | Customer payload arrived. RTC signals are NEVER surfaced here. |
onError | Object | A fatal condition you must act on — see What fires onError. |
onPeerJoined / onPeerLeft emit the RemotePeer directly (not wrapped in an event object — the JS { peer } wrapper is gone).
MeteredData
class MeteredData {
final String senderPeerId; // server-stamped — trust it
final Object? data; // whatever the sender sent
final MeteredDataKind kind; // broadcast | direct
final Map<String, Object?>? senderMetadata;
}
enum MeteredDataKind { broadcast, direct }
peer.onData.listen((m) {
if (m.kind == MeteredDataKind.broadcast) {
appendToRoomChat(m.senderPeerId, m.data);
} else {
appendToPrivateThread(m.senderPeerId, m.data);
}
});
kind discriminates channel-wide send from one-to-one sendTo. senderMetadata is the sender's JWT peerMetadata — present on broadcasts only if you joined with includeSenderMetadata: true, and on directs whenever the sender's JWT carried it.
onData is channel-scoped. It only fires for senders you've seen via presence on your current channel. The wire protocol allows cross-channel directs within a tenant, but MeteredPeer drops those — keeping onData consistent with onPeerJoined/onPeerLeft. For unscoped routing, use SignallingClient directly.
Security: use m.senderPeerId (server-stamped), never an inner data['from'] field a peer could spoof. And Dart maps from jsonDecode are plain Maps — copy known keys into your model rather than blindly spreading untrusted input.
What fires onError
onError consolidates the fatal conditions you must react to: terminal WebSocket close codes, fatal server-error frames, and tokenProvider failures past the retry threshold.
peer.onError.listen((err) {
// err is a StateError whose message carries the symbolic close-code name.
final msg = err.toString();
if (msg.contains('invalid_token') || msg.contains('token_expired')) {
showReloginPrompt();
} else if (msg.contains('account_suspended')) {
showBillingUi();
} else if (msg.contains('admin_disconnect')) {
showKickedUi();
} else if (msg.contains('channel_not_authorized')) {
showAccessDeniedUi();
} else if (msg.contains('token provider failed')) {
showAuthFlowBroken();
} else {
reportToCrashlytics(err);
}
});
The Dart onError emits a generic Object (currently a StateError) whose message carries the symbolic code (invalid_token, channel_not_authorized, account_suspended, admin_disconnect, plus the numeric close code). There is no err.name. The symbolic names are stable; match on them. Non-fatal server errors (rate limits, per-request rejections) do not fire onError — they reject the related Future and the connection stays open. See Errors & Codes.
What survives a reconnect
| Reference you held | Survives signalling-WS reconnect? | Notes |
|---|---|---|
peer instance | Yes | Same object across the drop. |
RemotePeer you held | Yes | Same identical() object. The SDK swaps the underlying PC behind it. |
Local media added via addStream | Yes | Reattached automatically to each survivor's new PC. |
remote.pc you cached in a variable | No | After reconcile, remote.pc returns a NEW PC; your old variable points at a closed one. Re-read it. |
The MediaStream you bound to a renderer | Object identity changes | stream.id is stable; the object is fresh. Re-assign renderer.srcObject on every onStreamAdded. |
A DataChannel opened via remote.pc | No | Tied to the old PC. Re-open after stateChanges → connected. |
Reconnect Best Practices has the full playbook.
Common pitfalls
- Calling
peer.sendbeforejoin()completes →MeteredPeerSendError(SendErrorCode.notJoined).await peer.join(...)first. - Assuming
send/sendTois P2P → both are server-routed. Open aDataChannelfor high-rate P2P data. - Caching
remote.pcacross a reconnect → goes stale. Re-read it, or re-wire onstateChanges → connected. - Not re-binding
renderer.srcObjecton eachonStreamAdded→ after a reconnect the renderer holds a dead stream and the tile freezes. - Forgetting
close()is terminal → construct a newMeteredPeerto rejoin. - Forgetting to stop local tracks →
close()leaves the camera light on.track.stop()in your cleanup.
See also
SignallingClient— the lower-level classMeteredPeerwrapsRemotePeer— whatonPeerJoinedandpeer.remotePeersgive youDataChannel— backpressure-aware wrapper for the P2P escape hatch- Errors & Codes — every error class, every close code
- Reconnect Best Practices — required production reading