zebra-report: zebra-spaces screen share via SFU

This commit is contained in:
russell@unturf.com 2026-06-01 14:41:29 -04:00
parent f3b653170e
commit 972439bed6
No known key found for this signature in database

View file

@ -56,6 +56,20 @@
* speaker) render their default ~300px control strip — they're just
* sinks for the WebRTC track, no UI required */
audio { display: none; }
/* screen-share thumbnails */
#screens { display: grid; grid-template-columns: 1fr; gap: 0.75rem; }
.screen-tile {
border: 1px solid #000; background: #000; padding: 0;
display: flex; flex-direction: column;
}
.screen-tile video {
width: 100%; height: auto; max-height: 70vh; display: block; background: #000;
}
.screen-tile .screen-meta {
background: #111; color: #ddd; font-size: 0.7rem; padding: 0.3rem 0.5rem;
display: flex; justify-content: space-between;
}
h1 {
font-family: 'chunkfiveregular', serif;
font-size: 3rem; font-weight: normal; letter-spacing: 0.02em;
@ -288,6 +302,21 @@
<div id="members"></div>
</section>
<section id="sec-screens" class="hidden">
<h2>screens</h2>
<div id="screens"></div>
</section>
<section id="sec-screen-share" class="hidden">
<h2>share</h2>
<div class="row">
<button id="btn-screen-share" class="invert">share screen</button>
<button id="btn-screen-stop" class="hidden">stop sharing</button>
<span class="note">shares a window or tab; if the picker offers an
"also share tab/system audio" checkbox the audio rides along.</span>
</div>
</section>
<section id="sec-listener-actions" class="hidden">
<h2>your hand</h2>
<div class="row">
@ -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');
</script>
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-05-31</span><br>
md5 <span class="stamp-md5">58a132641bdbf355a8b1981522ba0509</span><br>
sha256 <span class="stamp-sha">6dc07c94b3265f75f37fb69955ca1faf9bfaac12a99259948521cf901d740d3e</span><br>
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-01</span><br>
md5 <span class="stamp-md5">8adeffbcbd77249e26a6bbc8a5f4a57d</span><br>
sha256 <span class="stamp-sha">5e664e78df89fd2ba8316356b27a2081e578028e563c936a3180cc8df6bb0691</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>