@@ -502,6 +531,11 @@ const SFU_BASE = (new URLSearchParams(location.search).get('sfu')
let sfuPubPC = null, sfuPubPeerID = null;
let sfuSubPC = null, sfuSubPeerID = null, sfuSubEvents = null;
+let sfuScreenPC = null, sfuScreenPeerID = null, sfuScreenStream = null;
+/* incoming screen streams keyed by publisher pubkey hex (== streamID).
+ * Cleared when the corresponding publisher leaves (peer-left or boot). */
+const screenStreams = new Map(); // pubHex -> MediaStream
+const screenVideos = new Map(); // pubHex -> { tile, video }
/* SFU MediaStream cache keyed by PUBLISHER pubkey hex (= streamID set by the
* SFU's TrackLocal). Survives across host leave/rejoin: when a speaker drops
* we tear their audio element, but Pion often REUSES the transceiver on
@@ -546,6 +580,44 @@ function flushSfuStreams(){
}
}
+/* render one screen-share tile (or update its srcObject if it already
+ * exists). Removed on peer-left / boot / SFU track removal via
+ * removeScreenTile. */
+function renderScreenTile(pubHex, stream){
+ /* find the matching member for the title */
+ let label = shortHex(pubHex);
+ for (const [, mm] of members){
+ try { if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ label = mm.handle || label; break; } } catch(_){}
+ }
+ let entry = screenVideos.get(pubHex);
+ if (!entry){
+ const tile = document.createElement('div'); tile.className = 'screen-tile';
+ const video = document.createElement('video');
+ video.autoplay = true; video.playsInline = true; video.muted = false;
+ const meta = document.createElement('div'); meta.className = 'screen-meta';
+ const who = document.createElement('span'); who.textContent = 'screen: '+label;
+ const pop = document.createElement('button'); pop.className = 'small';
+ pop.textContent = 'fullscreen'; pop.onclick = () => video.requestFullscreen && video.requestFullscreen().catch(()=>{});
+ meta.appendChild(who); meta.appendChild(pop);
+ tile.appendChild(video); tile.appendChild(meta);
+ $('screens').appendChild(tile);
+ entry = { tile, video, label };
+ screenVideos.set(pubHex, entry);
+ }
+ entry.video.srcObject = stream;
+ try { const p = entry.video.play(); if (p && p.catch) p.catch(()=>{}); } catch(_){}
+ $('sec-screens').classList.remove('hidden');
+ logLine('', 'screen share: receiving '+label);
+}
+function removeScreenTile(pubHex){
+ const entry = screenVideos.get(pubHex);
+ if (!entry) return;
+ try { entry.video.srcObject = null; entry.tile.remove(); } catch(_){}
+ screenVideos.delete(pubHex);
+ screenStreams.delete(pubHex);
+ if (screenVideos.size === 0) $('sec-screens').classList.add('hidden');
+}
+
async function sfuPublish(){
if (sfuPubPC){ logLine('','sfu publish: already publishing'); return; }
if (!micStream || !myKeys || !roomID){
@@ -570,6 +642,49 @@ async function sfuPublish(){
logLine('', 'sfu: publishing as '+shortHex(sfuPubPeerID));
}
+/* ----- screen share ----- */
+async function sfuPublishScreen(){
+ if (sfuScreenPC || !myKeys || !roomID) return;
+ let stream;
+ try {
+ stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
+ } catch(e){ logLine('err','screen share cancelled: '+e.message); return; }
+ sfuScreenStream = stream;
+ const pc = new RTCPeerConnection(rtcConfig);
+ for (const tr of stream.getTracks()) pc.addTrack(tr, stream);
+ /* the user can stop the share from the browser's native "stop sharing"
+ * bar — propagate that into a clean unpublish */
+ stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishScreen(); });
+ await pc.setLocalDescription(await pc.createOffer());
+ await waitForIceGathering(pc);
+ const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID)
+ + '&pub=' + myKeys.pubHex + '&kind=screen';
+ let res;
+ try { res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ sdp: pc.localDescription.sdp }) }); }
+ catch(e){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; throw e; }
+ if (res.status === 403){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; handleBlocked('publish-screen'); return; }
+ if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; throw new Error('sfu publish-screen http '+res.status); }
+ const ans = await res.json();
+ await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
+ sfuScreenPC = pc; sfuScreenPeerID = ans.peer_id;
+ logLine('', 'sfu: sharing screen as '+shortHex(sfuScreenPeerID));
+ $('btn-screen-share').classList.add('hidden');
+ $('btn-screen-stop').classList.remove('hidden');
+}
+async function sfuUnpublishScreen(){
+ if (!sfuScreenPC && !sfuScreenStream) return;
+ const pid = sfuScreenPeerID;
+ if (sfuScreenStream){ sfuScreenStream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; }
+ if (sfuScreenPC){ try { sfuScreenPC.close(); } catch(_){} sfuScreenPC = null; sfuScreenPeerID = null; }
+ if (pid && roomID){
+ try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
+ }
+ $('btn-screen-share').classList.remove('hidden');
+ $('btn-screen-stop').classList.add('hidden');
+ logLine('', 'screen share stopped');
+}
+
async function sfuUnpublish(){
if (!sfuPubPC) return;
const pid = sfuPubPeerID;
@@ -584,8 +699,17 @@ async function sfuSubscribe(){
if (sfuSubPC || !roomID) return;
const pc = new RTCPeerConnection(rtcConfig);
pc.ontrack = (ev) => {
- const pubHex = ev.streams[0] ? ev.streams[0].id : '';
- if (!pubHex) return;
+ const sid = ev.streams[0] ? ev.streams[0].id : '';
+ if (!sid) return;
+ /* streamID format: PUBKEY (mic) | PUBKEY:screen (screen share).
+ * Route screens to the video tile renderer; mics to the audio path. */
+ if (sid.endsWith(':screen')){
+ const pubHex = sid.slice(0, -':screen'.length);
+ screenStreams.set(pubHex, ev.streams[0]);
+ renderScreenTile(pubHex, ev.streams[0]);
+ return;
+ }
+ const pubHex = sid;
/* cache stream by publisher pubkey so it survives the member's session
* uuid changing across leave/rejoin — see flushSfuStreams */
sfuStreamsByPubHex.set(pubHex, ev.streams[0]);
@@ -882,6 +1006,10 @@ async function handleSignal(raw){
{
const left = members.get(m.uuid);
if (left) logLine('', left.handle+' left');
+ /* if they were sharing a screen, drop the tile */
+ if (left && left.pubkey){
+ try { removeScreenTile(hex(unb64(left.pubkey))); } catch(_){}
+ }
members.delete(m.uuid);
handraise.delete(m.uuid);
tearPeer(m.uuid);
@@ -971,6 +1099,7 @@ async function handleSignal(raw){
logLine('err','you were removed from this space');
for (const u of [...peers.keys()]) tearPeer(u);
sfuUnpublish().catch(()=>{});
+ sfuUnpublishScreen().catch(()=>{});
sfuUnsubscribe().catch(()=>{});
dropMic();
}
@@ -1066,8 +1195,16 @@ function dropMic(){
if (micStream){ micStream.getTracks().forEach(t=>t.stop()); micStream = null; }
$('btn-mute').disabled = true;
}
+function updateScreenShareUI(){
+ /* the share-screen button is only available to people who can speak —
+ * publishing a screen via the SFU requires being a speaker anyway */
+ $('sec-screen-share').classList.toggle('hidden', !canSpeak(myRole));
+ if (!canSpeak(myRole) && (sfuScreenPC || sfuScreenStream)) sfuUnpublishScreen();
+}
+
function updateRoleUI(){
$('sec-listener-actions').classList.toggle('hidden', myRole !== 'listener');
+ updateScreenShareUI();
}
/* ==================================================================
@@ -1252,6 +1389,8 @@ function renderRoom(){
* ================================================================== */
$('btn-raise').addEventListener('click', () => { send({ type:'raise-hand' }); });
$('btn-lower').addEventListener('click', () => { send({ type:'lower-hand' }); });
+$('btn-screen-share').addEventListener('click', () => { sfuPublishScreen().catch(e => logLine('err','screen share: '+e.message)); });
+$('btn-screen-stop').addEventListener('click', () => { sfuUnpublishScreen(); });
/* notice banner — visible callouts for events that affect you directly
* (boot, role change). Auto-clears after 8s for info; stays for warn. */
let noticeTimer = null;
@@ -1286,6 +1425,7 @@ function handleBlocked(source){
if (ws){ try { ws.close(); } catch(_){} ws = null; }
for (const u of [...peers.keys()]) tearPeer(u);
sfuUnpublish().catch(()=>{});
+ sfuUnpublishScreen().catch(()=>{});
sfuUnsubscribe().catch(()=>{});
dropMic();
$('btn-leave').disabled = true;
@@ -1377,13 +1517,17 @@ $('btn-leave').addEventListener('click', async () => {
if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; }
for (const u of [...peers.keys()]) tearPeer(u);
if (ws){ try { ws.close(); } catch(_){} ws = null; }
- await sfuUnpublish(); await sfuUnsubscribe();
+ await sfuUnpublish(); await sfuUnpublishScreen(); await sfuUnsubscribe();
dropMic();
members.clear(); handraise.clear(); myUUID=''; myRole=''; hostUUID=''; outstandingInvite=null;
+ /* tear all screen tiles regardless of source — fresh slate next time */
+ for (const k of [...screenVideos.keys()]) removeScreenTile(k);
$('sec-room').classList.add('hidden');
$('sec-invite').classList.add('hidden');
$('sec-listener-actions').classList.add('hidden');
$('sec-share').classList.add('hidden');
+ $('sec-screen-share').classList.add('hidden');
+ $('sec-screens').classList.add('hidden');
hideNotice();
$('dot-call').className='dot warn';
setStatus('left', null);
@@ -1399,9 +1543,9 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');