diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index b72c079..9607626 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -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');