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