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
| Decision | Value |
|---|---|
| Topology | Full mesh (P2P between every pair) |
| Hard cap | 5 people per room, enforced server-side |
| Video budget | 3 simultaneous inbound video streams; audio for everyone |
| Video model | Demand-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 layout | Unchanged - the current full-bleed + draggable self-view |
| 3+ layout | Adaptive equal-tile grid; screen-sharer or pinned peer spotlights with the rest as a filmstrip |
| Tile interactions | Pin + double-tap fullscreen. No pinch-zoom on remote tiles. |
| Platform | Android 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:
subscriberelay arm. Add"subscribe"to therelaymatch so it forwards like the other targeted messages (it already stampssenderPeerIdand honourstargetPeerId).- Room cap.
const MAX_PEERS: usize = 5;- injoin(), 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-joined→addPeer(id, initiator = true)+createOfferFor(id)for each existing peer (the newcomer offers to everyone).user-joined→addPeer(peerId, initiator = false)- no offer (the later joiner offers to us). This is the glare rule generalised.offer/answer/candidate→ bysenderPeerId,candidatesends now targeted (each PC has its own ICE credentials).subscribe→setOutboundVideo(senderPeerId, payload.video).user-left→removePeer; ifparticipantsempty → waiting screen.room-full→ toast, tear down, back to Home.
Demand-driven video
A recomputeSubscriptions() helper, called on join / leave / pin / screen-share
change:
- Priority order: pinned peer → screen-sharer(s) → the rest in join order.
- Top
VIDEO_BUDGET = 3getwantVideo = true; sendsubscribe {video:true}to any that flipped on,subscribe {video:false}to any that flipped off (diff only). applyMeshProfile(peersEncodingForUs)- clamp capture one step below the user preset (≤ 640×360@24 floor), divide each sender’smaxBitrateBpsby 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 onLazyVerticalGrid.- Any
screenSharingparticipant or apinnedPeerId→ spotlight on top, filmstrip below. - Tiles use
TextureViewrenderers (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
RemotePeerinner class, 1-entry map, old API as delegates - no behaviour change.- Multi-peer API + per-
peerIdcallbacks; targetedcandidate; per-peer ICE recovery. MainActivityparticipant list +dispatchSignalingMessagerewrite +room-full.- Server:
subscribearm +MAX_PEERS;SubscribePayload; deploysignaling.rs. ParticipantGrid+ tile renderer lifecycle + adaptive / spotlight + pin + fullscreen.- Subscription manager (
recomputeSubscriptions,setOutboundVideo, send/recv). applyMeshProfileCPU/bandwidth clamp; force AUTO codec when grouped.CallForegroundService/CallHistory/ PiP generalisation; stats aggregation.