From 6118d59c5ceab3b62ea20025d66e28ac3705e570 Mon Sep 17 00:00:00 2001 From: sravan Date: Mon, 6 Jul 2026 13:20:59 +0530 Subject: [PATCH] =?UTF-8?q?feat(meetings):=20LiveKit=20SFU=20=E2=80=94=20p?= =?UTF-8?q?hase=202=20(client=20media=20plane,=20behind=20the=20flag)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meeting media flows through LiveKit when the server reports sfu:true; otherwise the P2P mesh is unchanged. Reuses the ENTIRE existing meeting UI + WS control plane (join/host/mute/screen/recording/transcript) and only swaps the transport: - vendored livekit-client 2.20 UMD (lazy-loaded; no build step). - sfuInit() reads /api/meetings/config at boot; sfuConnect() joins the LiveKit room with the minted token after the WS meeting-join. - remote tracks map back to the WS peerId via uid (LiveKit identity = app user id); per-peer stream prefers screen over camera, driving the existing sharing/stage UI. - toggleMic/Cam/Screen publish via LiveKit; local tracks reflected into meetLocalStream so tiles, active-speaker meter, canvas recording and transcript keep working. - meetMakePeer/peer-joined/peer-left/leaveMeeting branch on SFU.on; mesh path intact. Needs a running LiveKit server + live test (phase 3 ops) to exercise end-to-end. Co-Authored-By: Claude Opus 4.8 --- server/public/home.html | 82 ++++++++++++++++++- .../public/vendor/livekit-client.umd.min.js | 8 ++ 2 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 server/public/vendor/livekit-client.umd.min.js diff --git a/server/public/home.html b/server/public/home.html index 02e4081..8ac594f 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -771,7 +771,7 @@ - @@ -2396,6 +2396,63 @@ let meetRoomTx=false; // is the room transcription active (≥1 subscrib let meetSR=null; // my SpeechRecognition instance let meetStageId=null; // which shared screen is currently on the stage (peerId|'__local') function meetSend(o){ try{ if(meetWs && meetWs.readyState===1) meetWs.send(JSON.stringify(o)); }catch(_){} } + +// ================= LiveKit SFU media plane (feature-flagged) ================= +// When the server reports sfu:true, meeting MEDIA flows through LiveKit instead of the P2P mesh: +// each client uploads once and the SFU forwards to everyone, so rooms scale past ~5. Everything +// else — the meeting WebSocket control plane (join/host/mute/screen/recording/transcript), the tile +// UI, recording and transcript — is UNCHANGED. Tiles stay keyed by the WS peerId; LiveKit tracks map +// back to a peer via its uid (LiveKit identity = app user id = the peer's uid in meetPeerUids). +let SFU={ on:false, url:'', room:null, lib:null, peers:new Map() }; // peers: pid -> {audio,cam,screen} +async function sfuInit(){ try{ const c=await fetch('/api/meetings/config').then(r=>r.json()); SFU.on=!!(c&&c.sfu); SFU.url=(c&&c.url)||''; }catch(_){ SFU.on=false; } } +function sfuLoadLib(){ return new Promise((res,rej)=>{ if(window.LivekitClient) return res(window.LivekitClient); const s=document.createElement('script'); s.src='/vendor/livekit-client.umd.min.js'; s.onload=()=>res(window.LivekitClient); s.onerror=()=>rej(new Error('livekit sdk failed to load')); document.head.appendChild(s); }); } +function peerIdForUid(uid){ for(const [pid,u] of meetPeerUids){ if(u===uid) return pid; } return null; } +async function sfuConnect(){ + const LK=await sfuLoadLib(); SFU.lib=LK; + const tk=await postJSON('/api/meetings/token',{ room:meetRoom }); // per-user, per-room join credential + // adaptiveStream off: we attach media via manual srcObject (not track.attach), so LiveKit can't + // see tile visibility to pause/resume — keep all subscribed tracks flowing. dynacast stays on. + const room=new LK.Room({ adaptiveStream:false, dynacast:true }); SFU.room=room; + room.on(LK.RoomEvent.TrackSubscribed, (track,pub,participant)=>sfuAttach(pub,track,participant)); + room.on(LK.RoomEvent.TrackUnsubscribed, (track,pub,participant)=>sfuDetach(pub,track,participant)); + await room.connect(SFU.url, tk.token); +} +// Per-peer track bookkeeping → one tile stream that prefers the screen track over the camera, +// mirroring the mesh's single-video-per-tile model and driving the existing sharing/stage UI. +function sfuRebuild(pid){ + const p=SFU.peers.get(pid)||{}; const st=new MediaStream(); + const vid=p.screen||p.cam; if(vid) st.addTrack(vid); if(p.audio) st.addTrack(p.audio); + addTile(pid, st, meetNames.get(pid)||'Guest', false); setTileScreen(pid, !!p.screen); meetWatchStream(pid, st); +} +function sfuAttach(pub, track, participant){ + const pid=peerIdForUid(participant.identity); if(!pid) return; + const p=SFU.peers.get(pid)||{}; SFU.peers.set(pid,p); const mt=track.mediaStreamTrack; + if(track.kind==='audio') p.audio=mt; + else if(pub.source===SFU.lib.Track.Source.ScreenShare){ p.screen=mt; meetSharers.add(pid); } + else p.cam=mt; + sfuRebuild(pid); updateShareMode(); +} +function sfuDetach(pub, track, participant){ + const pid=peerIdForUid(participant.identity); if(!pid) return; const p=SFU.peers.get(pid); if(!p) return; const mt=track.mediaStreamTrack; + if(p.audio===mt) p.audio=null; else if(p.screen===mt){ p.screen=null; meetSharers.delete(pid); setTileScreen(pid,false); } else if(p.cam===mt) p.cam=null; + sfuRebuild(pid); updateShareMode(); +} +function sfuDropPeer(pid){ SFU.peers.delete(pid); } +// Reflect my LiveKit local mic/cam tracks into meetLocalStream so the self tile + active-speaker +// meter + canvas recording (which read tile