feat(meetings): LiveKit SFU — phase 2 (client media plane, behind the flag)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 13:20:59 +05:30
parent f1dbcd0f86
commit 6118d59c5c
2 changed files with 87 additions and 3 deletions
+79 -3
View File
@@ -771,7 +771,7 @@
<body>
<script src="/icons.js?v=4"></script>
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
<script>window.__BUILD='2026-07-06-batch40';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-06-batch41';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
// (emojis stay as plain Unicode). (#5)
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
@@ -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 <video> srcObjects) keep working unchanged.
function sfuSyncLocal(){
const lp=SFU.room&&SFU.room.localParticipant; if(!lp) return; const LK=SFU.lib;
const mic=lp.getTrackPublication(LK.Track.Source.Microphone), cam=lp.getTrackPublication(LK.Track.Source.Camera);
const mt=mic&&mic.track&&mic.track.mediaStreamTrack, ct=cam&&cam.track&&cam.track.mediaStreamTrack;
meetLocalStream.getTracks().forEach(t=>{ if(t!==mt&&t!==ct) meetLocalStream.removeTrack(t); });
if(mt&&meetLocalStream.getTracks().indexOf(mt)<0) meetLocalStream.addTrack(mt);
if(ct&&meetLocalStream.getTracks().indexOf(ct)<0) meetLocalStream.addTrack(ct);
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); meetWatchStream('__local', meetLocalStream);
}
async function sfuSetMic(on){ const lp=SFU.room&&SFU.room.localParticipant; if(!lp) return; await lp.setMicrophoneEnabled(on); sfuSyncLocal(); }
async function sfuSetCam(on){ const lp=SFU.room&&SFU.room.localParticipant; if(!lp) return; await lp.setCameraEnabled(on); sfuSyncLocal(); }
function sfuDisconnect(){ if(SFU.room){ try{ SFU.room.disconnect(); }catch(_){} SFU.room=null; } SFU.peers.clear(); }
// ================= end LiveKit SFU =================
function meetRailLive(on){ const b=document.querySelector('.railbtn[data-tab="meeting"]'); if(b) b.classList.toggle('live', !!on); }
function renderMeetingLobby(){
@@ -2639,6 +2696,8 @@ function meetWatchStream(id, stream){
function meetUnwatch(id){ const r=meetVU.get(id); if(!r) return; try{cancelAnimationFrame(r.raf);}catch(_){} try{r.src.disconnect();}catch(_){} try{r.ctx.close();}catch(_){} meetVU.delete(id); const t=document.getElementById('meet-tile-'+id); if(t) t.classList.remove('speaking'); }
function meetUnwatchAll(){ for(const id of Array.from(meetVU.keys())) meetUnwatch(id); }
function meetMakePeer(peerId, name){
// SFU mode: no P2P peer connection — just show the tile (avatar until LiveKit delivers media).
if(SFU.on){ addTile(peerId, null, meetNames.get(peerId)||name||'Guest', false); return null; }
const pc=new RTCPeerConnection(MEET_ICE);
const entry={ pc, name, vsender:null }; meetPeers.set(peerId, entry);
addTile(peerId, null, meetNames.get(peerId)||name||'Guest', false); // show the tile right away (avatar)
@@ -2655,6 +2714,15 @@ function meetMakePeer(peerId, name){
async function toggleScreen(){
if(meetScreen){ stopScreen(); return; }
if(!meetMultiShare && meetSharers.size>0){ toast('Someone is already sharing their screen'); return; }
if(SFU.on){
try{ await SFU.room.localParticipant.setScreenShareEnabled(true); }catch(e){ return; } // user cancelled / denied
const pub=SFU.room.localParticipant.getTrackPublication(SFU.lib.Track.Source.ScreenShare);
const st=pub&&pub.track&&pub.track.mediaStreamTrack; if(!st){ return; }
meetScreen=true; meetScreenStream=new MediaStream([st]);
try{ st.addEventListener('ended', ()=>stopScreen()); }catch(_){} // browser's native "Stop sharing" bar
addTile('__local', meetScreenStream, ((ME&&ME.name)?ME.name:'You')+' (screen)', true); setTileScreen('__local', true);
updateScreenBtn(); meetSend({ type:'meeting-screen', on:true }); return;
}
let ds; try{ ds=await navigator.mediaDevices.getDisplayMedia({ video:true, audio:false }); }
catch(e){ return; } // user cancelled the picker
const track=ds.getVideoTracks()[0]; if(!track){ try{ ds.getTracks().forEach(t=>t.stop()); }catch(_){} return; }
@@ -2669,6 +2737,7 @@ async function toggleScreen(){
}
function stopScreen(){
if(!meetScreen) return; meetScreen=false;
if(SFU.on){ try{ SFU.room.localParticipant.setScreenShareEnabled(false); }catch(_){} meetScreenStream=null; addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileScreen('__local', false); updateScreenBtn(); meetSend({ type:'meeting-screen', on:false }); return; }
const cam=meetLocalStream && meetLocalStream.getVideoTracks()[0];
for(const [,p] of meetPeers){ if(p.vsender){ try{ p.vsender.replaceTrack(cam||null); }catch(_){} } }
if(meetScreenStream){ try{ meetScreenStream.getTracks().forEach(t=>t.stop()); }catch(_){} meetScreenStream=null; }
@@ -2788,6 +2857,9 @@ async function onMeetMsg(e){
// Existing peers OFFER to me (their offers carry their tracks incl. any active screen share);
// I just set up the connections and wait. Avoids the "newcomer can't receive screen" bug.
for(const p of (m.peers||[])){ meetNames.set(p.peerId,p.name); if(p.avatar) meetAvatars.set(p.peerId,p.avatar); if(p.uid){ meetPeerUids.set(p.peerId,p.uid); meetInviteJoined(p.uid); } meetMakePeer(p.peerId,p.name); }
// SFU: connect to LiveKit for media once (peer uid→peerId map is populated above). Mic/cam are
// off at join, so nothing publishes yet — toggleMic/toggleCam publish on demand.
if(SFU.on){ try{ await sfuConnect(); }catch(err){ const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } }
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); // tell existing peers my state
if(meetIsHost) meetSend({type:'meeting-host', to:meetMyId}); // announce host so others know
refreshMeetPanel(); updateHostControls();
@@ -2797,7 +2869,7 @@ async function onMeetMsg(e){
if(m.type==='meeting-peer-joined'){
meetNames.set(m.peerId,m.name); if(m.avatar) meetAvatars.set(m.peerId,m.avatar); if(m.uid){ meetPeerUids.set(m.peerId,m.uid); meetInviteJoined(m.uid); }
const pc=meetMakePeer(m.peerId,m.name); // I'm an existing peer → I OFFER to the newcomer (carries my screen)
try{ const offer=await pc.createOffer(); await pc.setLocalDescription(offer); meetSend({type:'meeting-signal',to:m.peerId,data:{sdp:pc.localDescription}}); }catch(_){}
if(pc){ try{ const offer=await pc.createOffer(); await pc.setLocalDescription(offer); meetSend({type:'meeting-signal',to:m.peerId,data:{sdp:pc.localDescription}}); }catch(_){} } // (SFU: LiveKit handles media, no offer)
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
if(meetIsHost){ meetSend({type:'meeting-host', to:meetHostId}); meetSend({type:'meeting-sharemode', multi:meetMultiShare}); if(meetRec) meetSend({type:'meeting-recording', on:true}); }
if(meetScreen) meetSend({type:'meeting-screen', on:true});
@@ -2810,7 +2882,7 @@ async function onMeetMsg(e){
if(m.type==='meeting-peer-screen'){ if(m.on) meetSharers.add(m.from); else meetSharers.delete(m.from); setTileScreen(m.from, !!m.on); refreshMeetPanel(); return; }
if(m.type==='meeting-sharemode'){ meetMultiShare=!!m.multi; refreshMeetPanel(); return; }
if(m.type==='meeting-muteall'){ if(meetMic && meetLocalStream){ meetMic=false; meetLocalStream.getAudioTracks().forEach(t=>t.enabled=false); updateMicBtn(); setTileMute('__local', true); meetSend({type:'meeting-state', muted:true, camOff:!meetCam}); } toast('You were muted by the host'); return; }
if(m.type==='meeting-peer-left'){ const p=meetPeers.get(m.peerId); if(p){ try{p.pc.close();}catch(_){} meetPeers.delete(m.peerId);} meetSharers.delete(m.peerId); removeTile(m.peerId); refreshMeetPanel(); return; }
if(m.type==='meeting-peer-left'){ const p=meetPeers.get(m.peerId); if(p){ try{p.pc.close();}catch(_){} meetPeers.delete(m.peerId);} if(SFU.on) sfuDropPeer(m.peerId); meetSharers.delete(m.peerId); removeTile(m.peerId); refreshMeetPanel(); return; }
if(m.type==='meeting-signal'){
const from=m.from, d=m.data||{};
if(d.sdp){
@@ -2829,6 +2901,7 @@ function updateCamBtn(){ const b=document.getElementById('meetCamBtn'); if(b){ b
// Unmute acquires the mic on demand (no prompt until then) and renegotiates with peers.
async function toggleMic(){
if(!meetLocalStream) return;
if(SFU.on){ const next=!meetMic; try{ await sfuSetMic(next); meetMic=next; }catch(e){ toast('Microphone permission is required to unmute'); return; } updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
const hasTrack=meetLocalStream.getAudioTracks().length>0;
if(!hasTrack){
let astream; try{ astream=await navigator.mediaDevices.getUserMedia({ audio:true }); }
@@ -2845,6 +2918,7 @@ async function toggleMic(){
// renegotiates with every peer, so you can always turn video on once a meeting has started.
async function toggleCam(){
if(!meetLocalStream) return;
if(SFU.on){ const next=!meetCam; try{ await sfuSetCam(next); meetCam=next; meetAudioOnly=false; }catch(e){ toast('Camera permission is required to turn on video'); return; } updateCamBtn(); addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
const hasTrack=meetLocalStream.getVideoTracks().length>0;
if(!hasTrack){
let vstream; try{ vstream=await navigator.mediaDevices.getUserMedia({ video:true }); }
@@ -2878,6 +2952,7 @@ function leaveMeeting(forced){
if(meetScreen) stopScreen();
if(_recTimer){ clearInterval(_recTimer); _recTimer=null; } const _rn=document.getElementById('recNotice'); if(_rn) _rn.remove(); // clear any "Recording" badge
meetSend({type:'meeting-leave'});
if(SFU.on) sfuDisconnect(); // tear down the LiveKit room
meetUnwatchAll(); meetSharers.clear();
meetPeers.forEach(p=>{ try{p.pc.close();}catch(_){} }); meetPeers.clear(); meetNames.clear(); meetAvatars.clear(); meetPeerUids.clear(); meetCamOff.clear(); meetInvited.forEach(e=>{ if(e.timer) clearTimeout(e.timer); }); meetInvited.clear(); meetMuted.clear();
if(meetLocalStream){ try{ meetLocalStream.getTracks().forEach(t=>t.stop()); }catch(_){} meetLocalStream=null; }
@@ -3038,6 +3113,7 @@ async function doRegister(){
loadNotifs(); wireBell(); wireProfile();
placeHdrRight(); window.addEventListener('resize', placeHdrRight); // mobile: bell+profile live in the chat-list header (no top bar)
connectChatWs();
sfuInit(); // learn whether meetings use the LiveKit SFU or the P2P mesh (before any call starts)
reportInstall(); // desktop/mobile shell: record this install against the signed-in user
setupPush(); // register the notification service worker + subscribe to Web Push (if granted)
{ const cl=document.getElementById('chatlist'); if(cl) enablePullRefresh(cl, loadSidebar); } // pull-to-refresh the chat list