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:
+79
-3
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user