RemotePeer
What peer.remotePeers and the onPeerJoined stream give you — one RemotePeer per remote peer currently in your joined channel. You don't construct these; MeteredPeer creates them as peers join.
What you can read
remote.id // String — server-assigned, or the peer's JWT `sub` claim
remote.metadata // Map<String, Object?>? — the peer's `peerMetadata` from their JWT
remote.state // PeerConnectionState
remote.polite // bool — perfect-negotiation tie-breaker; you rarely need this
remote.pc // RtcPeerConnectionLike — escape hatch, see below
enum PeerConnectionState { idle, connecting, connected, reconnecting, closed }
metadata — the peer's JWT identity
peerMetadata is set on the JWT used to mint the peer's connection (typical fields: userId, username, avatarUrl, role). The server stamps it onto presence and surfaces it here.
peer.onPeerJoined.listen((remote) {
final name = remote.metadata?['username'] ?? 'Anonymous';
showNameTag(remote.id, name);
});
pk_live_ keys have no peerMetadata — it requires a backend-minted JWT. See Authentication.
Don't trust metadata for security. Your backend signed it, but a peer with a leaked JWT keeps using it. Use the JWT's server-enforced channels claim for access decisions, not client-side metadata.
metadata may refresh during reconcile. If the remote rotated their JWT during a signalling-WS drop, the SDK refreshes remote.metadata before stateChanges reaches connected on reconcile. No dedicated event fires — read it fresh whenever you need the latest value rather than caching an initial read.
What you can do
send(data) → Future<void>
Shorthand for peer.sendTo(remote.id, data). Server-routed via the wire-protocol send frame. See the routing trade-offs on MeteredPeer. Rejects after the peer is closed.
await remote.send({'whisper': 'private'});
toJson() → Map
print(remote.toJson()); // { id, state } — safe to log; no metadata (PII) or pc (cycles)
Streams
| Stream | Emits | When |
|---|---|---|
stateChanges | StateChange<PeerConnectionState> | Underlying peer-connection state changed. Drives per-peer "connecting…" UI. |
onTrack | RemoteTrackEvent | Remote sent a new media track. metadata is the per-track label the sender attached. |
onStreamAdded | RemoteStreamEvent | Fires once per MediaStream the remote sends, when its first track arrives. Preferred over onTrack for tile-per-stream UIs. |
onStreamRemoved | MediaStreamLike | Fires when a previously-seen stream has no live tracks left (the remote called removeStream/removeTrack, stopped the track, or the device disconnected). Symmetric with onStreamAdded. Not fired during reconcile. |
onDataChannel | RtcDataChannelLike | Remote opened a data channel via remote.pc.createDataChannel(...). Wrap it with DataChannel. |
onNegotiationError | Object | SDP negotiation (createOffer / setRemoteDescription) threw. Mostly ignorable — the SDK handles recovery. |
onIceCandidateError | Object | An inbound ICE candidate was rejected. Usually benign (stale candidate from a previous network). |
RemoteTrackEvent / RemoteStreamEvent
class RemoteTrackEvent {
final MediaStreamTrackLike track;
final List<MediaStreamLike> streams;
final StreamMetadata? metadata;
}
class RemoteStreamEvent {
final MediaStreamLike stream;
final StreamMetadata? metadata;
}
Rendering inbound media
Inbound streams are flutter_webrtc-backed. Unwrap with .native and assign to an RTCVideoRenderer:
final renderers = <String, RTCVideoRenderer>{};
peer.onPeerJoined.listen((remote) async {
final renderer = RTCVideoRenderer();
await renderer.initialize();
renderers[remote.id] = renderer;
remote.onStreamAdded.listen((ev) {
// Re-bind on EVERY event — a reconnect surfaces a fresh stream (same id).
if (ev.stream is FlutterWebrtcMediaStream) {
renderer.srcObject = (ev.stream as FlutterWebrtcMediaStream).native;
}
if (ev.metadata?['role'] == 'screen') markAsScreenShare(remote.id);
rebuild();
});
remote.onStreamRemoved.listen((_) {
renderer.srcObject = null;
rebuild();
});
});
Then render with RTCVideoView(renderer). The WebRTC Video Call guide has the full widget, including a _RemoteView holder that subscribes before the async initialize() so an early stream event isn't dropped.
onTrack vs onStreamAdded — which to use
Most call UIs render one tile per remote MediaStream → use onStreamAdded. Use the lower-level onTrack when you need per-track control (e.g. routing audio tracks separately from video):
remote.onTrack.listen((ev) {
if (ev.track.kind == 'audio') attachToAudioPipeline(ev.track);
if (ev.track.kind == 'video') attachToVideoTile(ev.streams.first, ev.metadata);
});
Reconcile note for onStreamAdded / onStreamRemoved
When the signalling WS reconnects, the SDK swaps each survivor's underlying RTCPeerConnection for a fresh one. The remote's tracks arrive again on the new PC, so onStreamAdded fires again — but with a new MediaStream object (same stream.id, different identity). Re-assign renderer.srcObject on every event.
onStreamRemoved is suppressed during reconcile — the old PC closing its tracks must not look like "they left." The fresh onStreamAdded on the new PC is the canonical re-bind point.
stateChanges — per-peer connection state
remote.stateChanges.listen((c) {
switch (c.to) {
case PeerConnectionState.connected: hideSpinner(remote.id);
case PeerConnectionState.reconnecting: showSpinner(remote.id);
case PeerConnectionState.closed: removeTile(remote.id);
default: break;
}
});
reconnecting covers both a WebRTC-level reconnect (ICE failed; the SDK is running its restart ladder) and a signalling-level reconnect (the WS dropped; the SDK is reconciling). Both surface the same way so your UI doesn't have to distinguish.
onNegotiationErrorUnlike the JavaScript SDK, the Flutter SDK does not surface a typed "ICE-restart exhausted" error. The automatic ICE-restart ladder (9 attempts, ~121 s) recovers most blips; if its budget is spent, the connection simply stops trying on its own and stays reconnecting. onNegotiationError only ever carries scrubbed, generally-ignorable errors — recovery comes from the next signalling-level reconnect (which replaces the connection) or a close() + re-join. Don't branch on these errors' text.
The pc escape hatch
remote.pc exposes the underlying RtcPeerConnectionLike for things the SDK doesn't surface directly — createDataChannel(...) for P2P data, configuration introspection, etc.
remote.stateChanges.listen((c) async {
if (c.to != PeerConnectionState.connected) return;
final raw = await remote.pc.createDataChannel('game-state');
final dc = DataChannel(raw);
dc.onMessage.listen((m) => handleGameTick(m.text));
});
Note createDataChannel returns a Future<RtcDataChannelLike> (it's async, unlike the browser's synchronous call).
Two warnings
1. remote.pc changes on reconnect. When the signalling WS reconnects, the SDK silently swaps each survivor's connection. Your RemotePeer reference stays valid, but remote.pc now points at a different object — a variable holding the old PC points at a closed one. Re-read remote.pc every time, or re-wire on stateChanges → connected (fires once on initial connect AND once per reconcile):
final channels = <String, DataChannel>{};
peer.onPeerJoined.listen((remote) {
remote.stateChanges.listen((c) async {
if (c.to != PeerConnectionState.connected) return;
await channels.remove(remote.id)?.close(); // discard stale DC
final raw = await remote.pc.createDataChannel('data');
final dc = DataChannel(raw)..onMessage.listen((m) => handle(m));
channels[remote.id] = dc;
});
});
2. Don't call pc.setConfiguration({'iceServers': …}). The SDK validates iceServers against an allowlist (stun:/stuns:/turn:/turns: schemes, size caps) before constructing the connection. Calling setConfiguration here bypasses that with no way for the SDK to revert it. For per-peer TURN, mint per-user JWTs with different metadata.iceServers claims instead.
What survives a reconnect
| Reference you held | Survives signalling-WS reconnect? |
|---|---|
The RemotePeer instance | Yes (same identical() object) |
remote.id | Yes |
remote.metadata | Yes (may be refreshed if the remote rotated its JWT) |
remote.state | Yes (reconnecting then back to connected) |
remote.pc | Object identity changes — re-read after stateChanges → connected |
Stream metadata from onStreamAdded | Yes — re-sent before track re-attachment on the new PC |
The MediaStream you bound to a renderer | Object identity changes — stream.id stable; re-bind on each onStreamAdded |
A DataChannel opened via remote.pc | No — tied to the old PC; re-open on stateChanges → connected |
See also
MeteredPeerDataChannel— backpressure-aware wrapper for thepcescape hatch- Data Channels & Low Latency
- Reconnect Best Practices