zebra-report: kind=game share + Region Capture crop — mirror
This commit is contained in:
parent
63134d00b4
commit
604f9f1af7
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 sfuSubPC = null, sfuSubPeerID = null, sfuSubEvents = null;
|
||||||
let sfuScreenPC = null, sfuScreenPeerID = null, sfuScreenStream = null;
|
let sfuScreenPC = null, sfuScreenPeerID = null, sfuScreenStream = null;
|
||||||
let sfuCameraPC = null, sfuCameraPeerID = null, sfuCameraStream = 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
|
/* cameraDeviceId is declared earlier so localStorage restore can write to it
|
||||||
* before the device-list refresh runs */
|
* before the device-list refresh runs */
|
||||||
/* incoming screen/camera streams keyed by publisher pubkey hex (== streamID).
|
/* 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 screenVideos = new Map(); // pubHex -> { tile, video }
|
||||||
const cameraStreams = new Map(); // pubHex -> MediaStream
|
const cameraStreams = new Map(); // pubHex -> MediaStream
|
||||||
const cameraVideos = new Map(); // pubHex -> { tile, video }
|
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 MediaStream cache keyed by PUBLISHER pubkey hex (= streamID set by the
|
||||||
* SFU's TrackLocal). Survives across host leave/rejoin: when a speaker drops
|
* SFU's TrackLocal). Survives across host leave/rejoin: when a speaker drops
|
||||||
* we tear their audio element, but Pion often REUSES the transceiver on
|
* 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)
|
* #cameras left column: camera thumbnails (always rendered)
|
||||||
* #screens-thumbs left column: screen thumbnails (always rendered) */
|
* #screens-thumbs left column: screen thumbnails (always rendered) */
|
||||||
const TILE_KINDS = {
|
const TILE_KINDS = {
|
||||||
screen: { thumbContainer:'screens-thumbs', tileClass:'screen-tile', labelPrefix:'screen', store: screenVideos, streams: screenStreams },
|
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 },
|
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 */
|
/* the active spotlight, or null when nothing is spotlit */
|
||||||
let spotlight = null; // { kind, pubHex, tile (DOM), video (DOM) }
|
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 meta = document.createElement('div'); meta.className = 'screen-meta';
|
||||||
const who = document.createElement('span'); who.textContent = 'game: ' + g.label;
|
const who = document.createElement('span'); who.textContent = 'game: ' + g.label;
|
||||||
const ctl = document.createElement('span');
|
const ctl = document.createElement('span');
|
||||||
/* 'share gameplay' captures the user's current tab via getDisplayMedia
|
/* 'share gameplay' publishes the iframe (via Region-Capture cropping
|
||||||
* and publishes it through the same SFU screen-share path so audience
|
* on Chromium, or whole-tab fallback elsewhere) to a SEPARATE SFU
|
||||||
* members see the game live. Chromium's preferCurrentTab hint makes
|
* publisher kind (kind=game) so it coexists with a regular screen
|
||||||
* the picker default to this tab. Only speakers can publish — listeners
|
* share. Listeners and other speakers see a gameplay tile in their
|
||||||
* can still watch the host's gameplay if someone shares. */
|
* screens-thumbs column. Only speakers can publish. */
|
||||||
if (canSpeak(myRole)){
|
if (canSpeak(myRole)){
|
||||||
const shareBtn = document.createElement('button');
|
const shareBtn = document.createElement('button');
|
||||||
shareBtn.className = 'small';
|
shareBtn.className = 'small';
|
||||||
const refreshLabel = () => { shareBtn.textContent = sfuScreenPC ? 'stop sharing' : 'share gameplay'; };
|
const refreshLabel = () => { shareBtn.textContent = sfuGamePC ? 'stop sharing' : 'share gameplay'; };
|
||||||
refreshLabel();
|
refreshLabel();
|
||||||
shareBtn.onclick = (ev) => {
|
shareBtn.onclick = (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
if (sfuScreenPC){
|
if (sfuGamePC){
|
||||||
sfuUnpublishScreen().then(refreshLabel);
|
sfuUnpublishGame().then(refreshLabel);
|
||||||
} else {
|
} else {
|
||||||
logLine('', 'share gameplay: pick this tab in the picker');
|
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);
|
ctl.appendChild(shareBtn);
|
||||||
|
|
@ -1887,7 +1894,7 @@ async function sfuPublish(){
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----- screen share ----- */
|
/* ----- screen share ----- */
|
||||||
async function sfuPublishScreen(opts){
|
async function sfuPublishScreen(){
|
||||||
if (sfuScreenPC || !myKeys || !roomID) return;
|
if (sfuScreenPC || !myKeys || !roomID) return;
|
||||||
let stream;
|
let stream;
|
||||||
try {
|
try {
|
||||||
|
|
@ -1896,16 +1903,11 @@ async function sfuPublishScreen(opts){
|
||||||
* gracefully; nothing is rejected. The constraint matters for the audio
|
* gracefully; nothing is rejected. The constraint matters for the audio
|
||||||
* side: without channelCount:2 + sampleRate:48000, getDisplayMedia on
|
* side: without channelCount:2 + sampleRate:48000, getDisplayMedia on
|
||||||
* Chrome can hand back mono 16kHz, which kills music quality. */
|
* 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} },
|
video: { width:{ideal:1920}, height:{ideal:1080}, frameRate:{ideal:30} },
|
||||||
audio: { echoCancellation:false, noiseSuppression:false, autoGainControl:false,
|
audio: { echoCancellation:false, noiseSuppression:false, autoGainControl:false,
|
||||||
channelCount:2, sampleRate:48000 },
|
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);
|
|
||||||
} catch(e){ logLine('err','screen share cancelled: '+e.message); return; }
|
} catch(e){ logLine('err','screen share cancelled: '+e.message); return; }
|
||||||
sfuScreenStream = stream;
|
sfuScreenStream = stream;
|
||||||
const vTracks = stream.getVideoTracks(), aTracks = stream.getAudioTracks();
|
const vTracks = stream.getVideoTracks(), aTracks = stream.getAudioTracks();
|
||||||
|
|
@ -1977,6 +1979,77 @@ async function sfuUnpublishScreen(){
|
||||||
logLine('', 'screen share stopped');
|
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) ----- */
|
/* ----- camera publish (kind=camera) ----- */
|
||||||
async function sfuPublishCamera(){
|
async function sfuPublishCamera(){
|
||||||
if (sfuCameraPC || !myKeys || !roomID) return;
|
if (sfuCameraPC || !myKeys || !roomID) return;
|
||||||
|
|
@ -2089,6 +2162,15 @@ async function sfuSubscribe(){
|
||||||
watchVideoTrackForRemoval(ev.track, () => removeCameraTile(pubHex));
|
watchVideoTrackForRemoval(ev.track, () => removeCameraTile(pubHex));
|
||||||
return;
|
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'){
|
if (kind !== 'mic'){
|
||||||
logLine('', 'sfu: unknown kind '+kind+' from '+shortHex(pubHex));
|
logLine('', 'sfu: unknown kind '+kind+' from '+shortHex(pubHex));
|
||||||
return;
|
return;
|
||||||
|
|
@ -2711,6 +2793,7 @@ async function handleSignal(raw){
|
||||||
sfuUnpublish().catch(()=>{});
|
sfuUnpublish().catch(()=>{});
|
||||||
sfuUnpublishScreen().catch(()=>{});
|
sfuUnpublishScreen().catch(()=>{});
|
||||||
sfuUnpublishCamera().catch(()=>{});
|
sfuUnpublishCamera().catch(()=>{});
|
||||||
|
sfuUnpublishGame().catch(()=>{});
|
||||||
sfuUnsubscribe().catch(()=>{});
|
sfuUnsubscribe().catch(()=>{});
|
||||||
dropMic();
|
dropMic();
|
||||||
}
|
}
|
||||||
|
|
@ -2820,6 +2903,7 @@ function updateScreenShareUI(){
|
||||||
* driven by canSpeak() above. If a role demotion lands while the
|
* driven by canSpeak() above. If a role demotion lands while the
|
||||||
* camera is live, force it off the same way the screen share is. */
|
* camera is live, force it off the same way the screen share is. */
|
||||||
if (!canSpeak(myRole) && (sfuCameraPC || sfuCameraStream)) sfuUnpublishCamera();
|
if (!canSpeak(myRole) && (sfuCameraPC || sfuCameraStream)) sfuUnpublishCamera();
|
||||||
|
if (!canSpeak(myRole) && (sfuGamePC || sfuGameStream)) sfuUnpublishGame();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRoleUI(){
|
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">
|
<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>
|
<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>
|
md5 <span class="stamp-md5">e02a2a8882f51ba59c410ca9ae472655</span><br>
|
||||||
sha256 <span class="stamp-sha">e728807c95404767e824dba0a87e9ad8446d739316c0c8f809e4befc0b9566d5</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">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>
|
<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>
|
</footer>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue