Carry huddle media over the SFU when LiveKit is configured (LC-610) #571
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/lc610-sfu-huddle-media"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The client half of #569. Closes LC-610 for the SFU media path; the mid-call handover is deferred and re-filed (see the bottom).
The scope changed mid-design, twice, for a real reason
I started to build the slice we first agreed - "SFU above the threshold, defer the handover" - and found it doesn't hold together. Under the mesh-for-2 / SFU-for-3+ model shipped in #569, AC1 (SFU carries 3+) requires AC3 (the handover), because a huddle grows one person at a time:
The only way all three end up together is to move P1 and P2 when the threshold is crossed - which is the handover. So the two are one feature, not two, and deferring the handover leaves a broken call, not a smaller one.
The chosen resolution: transport is fixed by server config, not by count. LiveKit configured -> the huddle is entirely SFU; not configured -> entirely mesh. No mid-call switch exists, so there is nothing to hand over. The token endpoint's
< SFU_MIN_PARTICIPANTSrefusal is removed; the constant stays as a documented seam so the handover work can reintroduce mesh-for-2 later without re-deriving the number.How it is wired
The transport decision has a chicken-and-egg. It must happen before joining, but the token endpoint gates on hub membership, which only exists after the WS
voice_join. Broken by having the huddle root carrydata-lc-huddle-sfu- a purelivekit::available()read, also added to/call/config- sovoice.jspicks the transport up front without needing a token first.huddle_sfu.js owns only the connection and the media. It renders into
voice.js's existing tile grid through a small hook API passed instart(), so an SFU huddle is visually identical to a mesh one and the tile/control DOM is not duplicated. This is the seam LC-613 will formalize; for now it is a hook object, not exported internals.The mesh path is untouched.
join/leave/toggleMute/toggleCamera/toggleScreeneach gain a guarded SFU branch at the top and fall through to the unchanged mesh body otherwise.leave()'s SFU branch returns before the mesh body; verified it resets the same state.Presence still flows.
voice_join/voice_leavefire on the SFU path too, so the hub roster, the LC-611 ring, and the transcription gate all keep working. The token endpoint gates on that membership, and the token fetch retries a few times to absorb the WS-vs-HTTP ordering (the frame may not be processed when the first HTTP request lands).Control parity maps onto LiveKit publish toggles:
setMicrophoneEnabled/setCameraEnabled/setScreenShareEnabled, with remote mute and active-speaker reflected fromRoomEvent.TrackMutedandActiveSpeakersChangedonto the same tile attributes the mesh uses.Tests
There is no JS test harness, so the server-observable behaviour is what's covered:
data-lc-huddle-sfuflips with LiveKit config, confirmed load-bearing by reverting the template attribute to a constantThe two env-mutating tests now share a
tokio::sync::Mutex- they were racing on the process-globalLETS_CHAT_LIVEKIT_*env in parallel, which is why the render test failed intermittently before the guard.For the client, I ran an adversarial review of both JS files. It found exactly one bug -
toggleMutereturning its intended post-toggle state rather than the actual unchanged state when the LiveKit call rejects (e.g. permission denied) - which is fixed here. No other findings.Full suite green (182 test binaries),
just checkclean.Deferred, and re-filed
The mid-call handover (mesh-for-2, upgrade to the SFU as it grows) is the only reason participant count would matter, and it is the risky part. It should be its own ticket, done after LC-614 lands JS-adjacent coverage. I will file it and link it under LC-596 unless you would rather fold it into the existing LC-610.
DM 1:1 is untouched: the endpoint refuses DM rooms and
call.jsis not involved.🤖 Generated with Claude Code
https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5