zebra-report: zebra-spaces screen share via SFU
This commit is contained in:
parent
f3b653170e
commit
972439bed6
1 changed files with 150 additions and 6 deletions
|
|
@ -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> · 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> · 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue