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