zebra-spaces: kind=game share — separate SFU publisher slot + Region Capture crop

Fix two complaints from fox:
1. share-gameplay used to grab the only sfuScreenPC slot, so a normal
   screen-share would be torn down. Now uses dedicated sfuGamePC /
   sfuGamePeerID / sfuGameStream so the two coexist freely.
2. The browser picker exposes whole tabs, not iframes. We now call
   CropTarget.fromElement(iframe) → videoTrack.cropTo(cropTarget)
   (Chromium Region Capture API) which restricts the captured frame
   to the iframe's rect — audience sees only the gameplay, none of
   the surrounding meeting UI. Firefox + Safari lack CropTarget so
   they fall back to whole-tab capture; user-controlled.

Paired with SFU 855f798 which adds 'game' to the kind allowlist.
streamID format: <16hex>-game (parallel to -screen / -camera).

Client wiring:
- new gameStreams / gameVideos Maps and TILE_KINDS.gameshare
- ontrack: kind === 'game' → renderVideoTile('gameshare', ...) with
  label prefix 'gameplay'. Listeners' screens-thumbs column gets the
  tile alongside any regular screen-shares from the same speaker.
- sfuPublishGame(iframe) / sfuUnpublishGame() mirror the screen helpers
- game-tile's 'share gameplay' button toggles sfuPublishGame ↔
  sfuUnpublishGame (not sfuPublishScreen — that's now untouched)
- leave / role-demotion / boot all clean up game-share too
This commit is contained in:
Russell Ballestrini 2026-06-02 12:25:55 -04:00
parent 5c26bc3232
commit df4668f14d
No known key found for this signature in database

View file

@ -1407,6 +1407,10 @@ let sfuPubPC = null, sfuPubPeerID = null;
let sfuSubPC = null, sfuSubPeerID = null, sfuSubEvents = null;
let sfuScreenPC = null, sfuScreenPeerID = null, sfuScreenStream = null;
let sfuCameraPC = null, sfuCameraPeerID = null, sfuCameraStream = null;
/* game-share rides its own SFU publisher kind so it can coexist with
* a regular screen share. Region Capture (Chromium) crops the captured
* video to the iframe only so audience members see just the game. */
let sfuGamePC = null, sfuGamePeerID = null, sfuGameStream = null;
/* cameraDeviceId is declared earlier so localStorage restore can write to it
* before the device-list refresh runs */
/* incoming screen/camera streams keyed by publisher pubkey hex (== streamID).
@ -1415,6 +1419,8 @@ const screenStreams = new Map(); // pubHex -> MediaStream
const screenVideos = new Map(); // pubHex -> { tile, video }
const cameraStreams = new Map(); // pubHex -> MediaStream
const cameraVideos = new Map(); // pubHex -> { tile, video }
const gameStreams = new Map(); // pubHex -> MediaStream (kind=game-share)
const gameVideos = 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
@ -1509,8 +1515,9 @@ function watchVideoTrackForRemoval(track, removeFn){
* #cameras left column: camera thumbnails (always rendered)
* #screens-thumbs left column: screen thumbnails (always rendered) */
const TILE_KINDS = {
screen: { thumbContainer:'screens-thumbs', tileClass:'screen-tile', labelPrefix:'screen', store: screenVideos, streams: screenStreams },
camera: { thumbContainer:'cameras', tileClass:'camera-tile', labelPrefix:'camera', store: cameraVideos, streams: cameraStreams },
screen: { thumbContainer:'screens-thumbs', tileClass:'screen-tile', labelPrefix:'screen', store: screenVideos, streams: screenStreams },
camera: { thumbContainer:'cameras', tileClass:'camera-tile', labelPrefix:'camera', store: cameraVideos, streams: cameraStreams },
gameshare: { thumbContainer:'screens-thumbs', tileClass:'screen-tile', labelPrefix:'gameplay', store: gameVideos, streams: gameStreams },
};
/* the active spotlight, or null when nothing is spotlit */
let spotlight = null; // { kind, pubHex, tile (DOM), video (DOM) }
@ -1702,23 +1709,23 @@ function setSpotlight(kind, pubHex){
const meta = document.createElement('div'); meta.className = 'screen-meta';
const who = document.createElement('span'); who.textContent = 'game: ' + g.label;
const ctl = document.createElement('span');
/* 'share gameplay' captures the user's current tab via getDisplayMedia
* and publishes it through the same SFU screen-share path so audience
* members see the game live. Chromium's preferCurrentTab hint makes
* the picker default to this tab. Only speakers can publish — listeners
* can still watch the host's gameplay if someone shares. */
/* 'share gameplay' publishes the iframe (via Region-Capture cropping
* on Chromium, or whole-tab fallback elsewhere) to a SEPARATE SFU
* publisher kind (kind=game) so it coexists with a regular screen
* share. Listeners and other speakers see a gameplay tile in their
* screens-thumbs column. Only speakers can publish. */
if (canSpeak(myRole)){
const shareBtn = document.createElement('button');
shareBtn.className = 'small';
const refreshLabel = () => { shareBtn.textContent = sfuScreenPC ? 'stop sharing' : 'share gameplay'; };
const refreshLabel = () => { shareBtn.textContent = sfuGamePC ? 'stop sharing' : 'share gameplay'; };
refreshLabel();
shareBtn.onclick = (ev) => {
ev.stopPropagation();
if (sfuScreenPC){
sfuUnpublishScreen().then(refreshLabel);
if (sfuGamePC){
sfuUnpublishGame().then(refreshLabel);
} else {
logLine('', 'share gameplay: pick this tab in the picker');
sfuPublishScreen({ preferCurrentTab: true }).then(refreshLabel).catch(()=>refreshLabel());
sfuPublishGame(iframe).then(refreshLabel).catch(()=>refreshLabel());
}
};
ctl.appendChild(shareBtn);
@ -1887,7 +1894,7 @@ async function sfuPublish(){
}
/* ----- screen share ----- */
async function sfuPublishScreen(opts){
async function sfuPublishScreen(){
if (sfuScreenPC || !myKeys || !roomID) return;
let stream;
try {
@ -1896,16 +1903,11 @@ async function sfuPublishScreen(opts){
* gracefully; nothing is rejected. The constraint matters for the audio
* side: without channelCount:2 + sampleRate:48000, getDisplayMedia on
* Chrome can hand back mono 16kHz, which kills music quality. */
const constraints = {
stream = await navigator.mediaDevices.getDisplayMedia({
video: { width:{ideal:1920}, height:{ideal:1080}, frameRate:{ideal:30} },
audio: { echoCancellation:false, noiseSuppression:false, autoGainControl:false,
channelCount:2, sampleRate:48000 },
};
/* Chromium-only hint — when sharing a game we want the picker to
* default to the current tab so the user can just click 'share'
* and the iframe content (gameplay) goes out without hunting. */
if (opts && opts.preferCurrentTab) constraints.preferCurrentTab = true;
stream = await navigator.mediaDevices.getDisplayMedia(constraints);
channelCount:2, sampleRate:48000 }
});
} catch(e){ logLine('err','screen share cancelled: '+e.message); return; }
sfuScreenStream = stream;
const vTracks = stream.getVideoTracks(), aTracks = stream.getAudioTracks();
@ -1977,6 +1979,77 @@ async function sfuUnpublishScreen(){
logLine('', 'screen share stopped');
}
/* ----- game-share publish (kind=game) — separate slot from regular
* screen-share so the two can coexist. Region Capture (Chromium) crops
* the tab capture to just the iframe element so audience members see
* the gameplay without the surrounding UI. Firefox lacks Region
* Capture so it falls back to whole-tab; the user controls what to
* share via the picker. */
async function sfuPublishGame(iframe){
if (sfuGamePC || !myKeys || !roomID) return;
let stream;
try {
const constraints = {
video: { width:{ideal:1920}, height:{ideal:1080}, frameRate:{ideal:30} },
audio: { echoCancellation:false, noiseSuppression:false, autoGainControl:false,
channelCount:2, sampleRate:48000 },
};
constraints.preferCurrentTab = true; /* Chromium hint */
stream = await navigator.mediaDevices.getDisplayMedia(constraints);
} catch(e){ logLine('err','game-share cancelled: '+e.message); return; }
sfuGameStream = stream;
/* Region Capture: crop the captured video to the iframe element. Only
* works if user picked the current tab + browser supports CropTarget. */
try {
if (iframe && window.CropTarget && typeof CropTarget.fromElement === 'function'){
const cropTarget = await CropTarget.fromElement(iframe);
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack && typeof videoTrack.cropTo === 'function'){
await videoTrack.cropTo(cropTarget);
logLine('', 'game-share: cropped to iframe');
}
}
} catch(e){ logLine('', 'game-share: crop failed ('+e.message+'), sharing full tab'); }
const pc = new RTCPeerConnection(rtcConfig);
for (const tr of stream.getTracks()){
if (tr.kind === 'video') tr.contentHint = 'motion';
if (tr.kind === 'audio') tr.contentHint = 'music';
pc.addTrack(tr, stream);
}
stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishGame(); });
const offer = await pc.createOffer();
offer.sdp = preferStereoOpus(offer.sdp, 256000, { music: true });
await pc.setLocalDescription(offer);
await waitForIceGathering(pc);
const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID)
+ '&pub=' + myKeys.pubHex + '&kind=game';
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()); sfuGameStream = null; throw e; }
if (res.status === 403){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuGameStream = null; handleBlocked('publish-game'); return; }
if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuGameStream = null; throw new Error('sfu publish-game http '+res.status); }
const ans = await res.json();
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
sfuGamePC = pc; sfuGamePeerID = ans.peer_id;
for (const s of pc.getSenders()){
if (!s.track) continue;
if (s.track.kind === 'video') setSenderMaxBitrate(s, 4000000);
if (s.track.kind === 'audio') setSenderMaxBitrate(s, 256000);
}
logLine('', 'sfu: sharing gameplay as '+shortHex(sfuGamePeerID));
}
async function sfuUnpublishGame(){
if (!sfuGamePC && !sfuGameStream) return;
const pid = sfuGamePeerID;
if (sfuGameStream){ sfuGameStream.getTracks().forEach(t=>t.stop()); sfuGameStream = null; }
if (sfuGamePC){ try { sfuGamePC.close(); } catch(_){} sfuGamePC = null; sfuGamePeerID = null; }
if (pid && roomID){
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
}
logLine('', 'game-share stopped');
}
/* ----- camera publish (kind=camera) ----- */
async function sfuPublishCamera(){
if (sfuCameraPC || !myKeys || !roomID) return;
@ -2089,6 +2162,15 @@ async function sfuSubscribe(){
watchVideoTrackForRemoval(ev.track, () => removeCameraTile(pubHex));
return;
}
if (kind === 'game'){
/* a publisher is sharing their gameplay (Region-Capture cropped
* iframe). Route to its own TILE_KIND so it coexists with a
* normal screen-share from the same person. */
gameStreams.set(pubHex, ev.streams[0]);
renderVideoTile('gameshare', pubHex, ev.streams[0]);
watchVideoTrackForRemoval(ev.track, () => removeVideoTile('gameshare', pubHex));
return;
}
if (kind !== 'mic'){
logLine('', 'sfu: unknown kind '+kind+' from '+shortHex(pubHex));
return;
@ -2711,6 +2793,7 @@ async function handleSignal(raw){
sfuUnpublish().catch(()=>{});
sfuUnpublishScreen().catch(()=>{});
sfuUnpublishCamera().catch(()=>{});
sfuUnpublishGame().catch(()=>{});
sfuUnsubscribe().catch(()=>{});
dropMic();
}
@ -2820,6 +2903,7 @@ function updateScreenShareUI(){
* driven by canSpeak() above. If a role demotion lands while the
* camera is live, force it off the same way the screen share is. */
if (!canSpeak(myRole) && (sfuCameraPC || sfuCameraStream)) sfuUnpublishCamera();
if (!canSpeak(myRole) && (sfuGamePC || sfuGameStream)) sfuUnpublishGame();
}
function updateRoleUI(){
@ -3407,8 +3491,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
<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-06-02</span><br>
md5 <span class="stamp-md5">7a6f4dd81ede2811d219eabc4603f026</span><br>
sha256 <span class="stamp-sha">e728807c95404767e824dba0a87e9ad8446d739316c0c8f809e4befc0b9566d5</span><br>
md5 <span class="stamp-md5">e02a2a8882f51ba59c410ca9ae472655</span><br>
sha256 <span class="stamp-sha">08b05ffb874a5068e05edd061ce00d41b1bc1711dd35a575074628785f812fef</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>