Skip to main content

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​

StreamEmitsWhen
stateChangesStateChange<PeerConnectionState>Underlying peer-connection state changed. Drives per-peer "connecting…" UI.
onTrackRemoteTrackEventRemote sent a new media track. metadata is the per-track label the sender attached.
onStreamAddedRemoteStreamEventFires once per MediaStream the remote sends, when its first track arrives. Preferred over onTrack for tile-per-stream UIs.
onStreamRemovedMediaStreamLikeFires 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.
onDataChannelRtcDataChannelLikeRemote opened a data channel via remote.pc.createDataChannel(...). Wrap it with DataChannel.
onNegotiationErrorObjectSDP negotiation (createOffer / setRemoteDescription) threw. Mostly ignorable — the SDK handles recovery.
onIceCandidateErrorObjectAn 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.

No terminal onNegotiationError

Unlike 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.

// Creating side (exactly one per pair — the impolite one), in your
// onPeerJoined handler; opening the channel is what starts the P2P
// connection.
if (!remote.polite) {
final raw = await remote.pc.createDataChannel('game-state');
final dc = DataChannel(raw);
dc.onMessage.listen((m) => handleGameTick(m.text ?? ''));
}

// Receiving side — the same channel arrives on onDataChannel:
remote.onDataChannel.listen((raw) {
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. For data channels, the impolite side opens on onPeerJoined (creating the channel is what starts the connection — don't gate it on stateChanges → connected, which a data-only app never reaches until a channel exists) and re-opens when the peer returns to joined after a reconnect; both sides wire onDataChannel, so one channel carries both directions:

final channels = <String, DataChannel>{};

Future<void> wireDataChannel(RemotePeer remote, RtcDataChannelLike raw) async {
await channels.remove(remote.id)?.close(); // discard stale DC
final dc = DataChannel(raw)..onMessage.listen((m) => handle(m));
channels[remote.id] = dc;
}

Future<void> maybeCreateChannel(RemotePeer remote) async {
if (remote.polite) return; // exactly one creator per pair
await wireDataChannel(remote, await remote.pc.createDataChannel('data'));
}

peer.onPeerJoined.listen((remote) {
remote.onDataChannel.listen((raw) => wireDataChannel(remote, raw)); // receiving side
maybeCreateChannel(remote);
});

peer.stateChanges.listen((c) {
if (c.from == MeteredPeerState.reconnecting &&
c.to == MeteredPeerState.joined) {
peer.remotePeers.forEach(maybeCreateChannel); // fresh transports are in place
}
});

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 heldSurvives signalling-WS reconnect?
The RemotePeer instanceYes (same identical() object)
remote.idYes
remote.metadataYes (may be refreshed if the remote rotated its JWT)
remote.stateYes (reconnecting then back to connected)
remote.pcObject identity changes — re-read it fresh each time you need it (it's replaced during recovery)
Stream metadata from onStreamAddedYes — re-sent before track re-attachment on the new PC
The MediaStream you bound to a rendererObject identity changes — stream.id stable; re-bind on each onStreamAdded
A DataChannel opened via remote.pcNo — tied to the old PC; re-open when the peer returns to joined after a reconnect

See also​