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:
parent
5c26bc3232
commit
df4668f14d
1 changed files with 106 additions and 22 deletions
|
|
@ -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> · 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue