Skip to Content
RoadmapMulti-party calls

Multi-party calls (mesh)

Status

Designed and partially prototyped on a branch. Not shipped. This page is the design of record.

Goal

Let several people share a room code and see each other in a clean grid, on a mesh topology - each client holds a PeerConnection to every other client, no media server.

Decisions locked

DecisionValue
TopologyFull mesh (P2P between every pair)
Hard cap5 people per room, enforced server-side
Video budget3 simultaneous inbound video streams; audio for everyone
Video modelDemand-driven - you pull video only from pinned peers + the top of the grid, and signal each peer to pause/resume the video they send you
2-person layoutUnchanged - the current full-bleed + draggable self-view
3+ layoutAdaptive equal-tile grid; screen-sharer or pinned peer spotlights with the rest as a filmstrip
Tile interactionsPin + double-tap fullscreen. No pinch-zoom on remote tiles.
PlatformAndroid only. The web client stays 2-person.

Why mesh, not an SFU

At ≤5 participants a mesh means N−1 encodes and N−1 decodes per phone - a modern SoC handles that, and the demand-driven video budget caps it at 3 inbound streams regardless of room size. In exchange you keep the core privacy property: no server ever sees a frame. An SFU would break that and is explicitly a non-goal.

Server changes (small)

The server is already an N-peer relay. Two additions:

  1. subscribe relay arm. Add "subscribe" to the relay match so it forwards like the other targeted messages (it already stamps senderPeerId and honours targetPeerId).
  2. Room cap. const MAX_PEERS: usize = 5; - in join(), before inserting, if the room is full send the newcomer { "type": "room-full", "roomId" } and return without inserting or broadcasting.

main.rs is not touched.

Client changes

WebRtcClient → per-peer connections, one shared media stack

Introduce private inner class RemotePeer(peerId, name, initiator) holding its own pc, videoSender, audioSender, remoteVideoTrack, renderer sink, connection state, pending-ICE list, and per-PC stats baselines.

WebRtcClient keeps private val peers = LinkedHashMap<String, RemotePeer>() and stays the sole owner of everything shared: the factory, ADM, local audio/video tracks, sources, capturers, SurfaceTextureHelpers, zoom controller, RNNoise, and all SDP munging.

New API (all fan out over the map):

addPeer(peerId, name, initiator): RemotePeer removePeer(peerId); closeAllPeers() createOfferFor(peerId, cb); handleOfferFrom(peerId, sdp, cb) handleAnswerFrom(peerId, sdp); addIceFor(peerId, cand) attachRendererFor(peerId, sink); detachRendererFor(peerId, sink) setOutboundVideo(peerId, enabled) // encodings[0].active - no renegotiation forEachPeer { … }

Local-track fan-out: camera on/off and screen share already mutate the shared videoSource; only the bitrate/codec appliers and the mic-mode track swap need to loop forEachPeer.

MainActivity → participant list

Replace the wall of remote* scalars with:

data class Participant( val peerId: String, val name: String, val connState: PeerConnection.PeerConnectionState?, val cameraOn: Boolean = true, val screenSharing: Boolean = false, val avatar: AvatarSpec = AvatarSpec.Initial, val contentRotation: Int? = null, val contentLandscape: Boolean? = null, val initiator: Boolean, // true = we offered to them val wantVideo: Boolean = false, // we're subscribed to their video ) private val participants = mutableStateListOf<Participant>() private var pinnedPeerId: String? by mutableStateOf(null)

dispatchSignalingMessage routes by senderPeerId:

  • room-joinedaddPeer(id, initiator = true) + createOfferFor(id) for each existing peer (the newcomer offers to everyone).
  • user-joinedaddPeer(peerId, initiator = false) - no offer (the later joiner offers to us). This is the glare rule generalised.
  • offer / answer / candidate → by senderPeerId, candidate sends now targeted (each PC has its own ICE credentials).
  • subscribesetOutboundVideo(senderPeerId, payload.video).
  • user-leftremovePeer; if participants empty → waiting screen.
  • room-full → toast, tear down, back to Home.

Demand-driven video

A recomputeSubscriptions() helper, called on join / leave / pin / screen-share change:

  1. Priority order: pinned peer → screen-sharer(s) → the rest in join order.
  2. Top VIDEO_BUDGET = 3 get wantVideo = true; send subscribe {video:true} to any that flipped on, subscribe {video:false} to any that flipped off (diff only).
  3. applyMeshProfile(peersEncodingForUs) - clamp capture one step below the user preset (≤ 640×360@24 floor), divide each sender’s maxBitrateBps by the active count, and force AUTO codec - N strict hardware HEVC encoders with the per-PC bitrate seed don’t co-exist reliably.

UI - ParticipantGrid

  • remotes.size == 1 && no screen-share → the existing 1:1 code path, byte-for-byte.
  • >= 2 remotes → tile grid (self is a tile). 2 → stacked halves; 3–4 → 2×2; 5 → 2×3 with one empty. Built on LazyVerticalGrid.
  • Any screenSharing participant or a pinnedPeerId → spotlight on top, filmstrip below.
  • Tiles use TextureView renderers (SurfaceView z-orders fight in a grid). Tap = pin/unpin, double-tap = fullscreen.

Migration safety

Commit 1 is a pure refactor: extract RemotePeer, back it with a 1-entry map, keep the old public method names as thin delegates. Build + run the live 1:1 call and confirm it’s byte-for-byte before any N-peer code. The whole 3+ path is gated behind participants.size >= 2; <= 1 is the untouched legacy path - layout and applyVideoQuality.

Commit breakdown

  1. RemotePeer inner class, 1-entry map, old API as delegates - no behaviour change.
  2. Multi-peer API + per-peerId callbacks; targeted candidate; per-peer ICE recovery.
  3. MainActivity participant list + dispatchSignalingMessage rewrite + room-full.
  4. Server: subscribe arm + MAX_PEERS; SubscribePayload; deploy signaling.rs.
  5. ParticipantGrid + tile renderer lifecycle + adaptive / spotlight + pin + fullscreen.
  6. Subscription manager (recomputeSubscriptions, setOutboundVideo, send/recv).
  7. applyMeshProfile CPU/bandwidth clamp; force AUTO codec when grouped.
  8. CallForegroundService / CallHistory / PiP generalisation; stats aggregation.