zebra-spaces: rebuild <video> on MSID supplant — autoplay grant resets

Live-room defect (2026-06-03): Will refreshed his browser; the
supplant fired a SECOND ontrack for kind=camera pub=Will at +16s.
The page swapped srcObject on the existing <video>, called play(),
got 'fetching process for the media resource was aborted by the
user agent at the user's request' — browser refused to start a new
playback session on the same element after its autoplay grant had
already been consumed. Tile sat black on the moderator's screen.

Fix: swapFreshVideoElement() — on supplant, replace the <video>
with a freshly-built one carrying the same attrs. A brand-new
<video> is eligible for muted-autoplay even when the prior one had
its play() rejected, so the supplant lands cleanly without needing
a tap. Applied to both the thumbnail and the spotlight tile.

Mesh test harness extracts the helper so renderVideoTile still
runs end-to-end under the sandbox.
This commit is contained in:
Russell Ballestrini 2026-06-03 16:08:01 -04:00
parent 3ca9042e54
commit fb7228b289
No known key found for this signature in database
2 changed files with 44 additions and 3 deletions

View file

@ -51,6 +51,7 @@ function extractConst(name){
const watchSrc = extractFn(/function watchVideoTrackForRemoval\(/); const watchSrc = extractFn(/function watchVideoTrackForRemoval\(/);
const firstFrameSrc = extractFn(/function watchFirstFrame\(/); const firstFrameSrc = extractFn(/function watchFirstFrame\(/);
const swapVideoSrc = extractFn(/function swapFreshVideoElement\(/);
const renderTileSrc = extractFn(/function renderVideoTile\(/); const renderTileSrc = extractFn(/function renderVideoTile\(/);
const removeTileSrc = extractFn(/function removeVideoTile\(/); const removeTileSrc = extractFn(/function removeVideoTile\(/);
const renderScreenShim = extractFn(/function renderScreenTile\(/); const renderScreenShim = extractFn(/function renderScreenTile\(/);
@ -231,6 +232,7 @@ function makeBrowser(name, pubKeyHex){
tileKindsSrc + '\n' + tileKindsSrc + '\n' +
watchSrc + '\n' + watchSrc + '\n' +
firstFrameSrc + '\n' + firstFrameSrc + '\n' +
swapVideoSrc + '\n' +
renderTileSrc + '\n' + renderTileSrc + '\n' +
removeTileSrc + '\n' + removeTileSrc + '\n' +
renderScreenShim + '\n' + renderScreenShim + '\n' +

View file

@ -1869,6 +1869,26 @@ function updateContainerVisibility(){
$('tiles-h2').classList.toggle('hidden', !anyThumb); $('tiles-h2').classList.toggle('hidden', !anyThumb);
} }
/* Replace a <video> with a freshly-built one carrying the same
* autoplay/playsinline/muted attributes, in the same DOM slot. Returns
* the new element so the caller can update its reference. Browsers
* treat a brand-new <video> as eligible for muted-autoplay even when
* the old element had its play() rejected — so use this on any
* supplant where srcObject would otherwise be swapped mid-life. */
function swapFreshVideoElement(oldVideo){
if (!oldVideo) return oldVideo;
const fresh = document.createElement('video');
fresh.setAttribute('autoplay', '');
fresh.setAttribute('playsinline', '');
fresh.setAttribute('muted', '');
fresh.autoplay = true; fresh.playsInline = true; fresh.muted = true;
if (oldVideo.parentNode){
try { oldVideo.srcObject = null; } catch(_){}
oldVideo.parentNode.replaceChild(fresh, oldVideo);
}
return fresh;
}
/* build a tile DOM. opts.thumb = true for thumb-style (no fullscreen /* build a tile DOM. opts.thumb = true for thumb-style (no fullscreen
* button, gets .tile-thumb class). Returns { tile, video, fsBtn }. */ * button, gets .tile-thumb class). Returns { tile, video, fsBtn }. */
function buildTile(kind, pubHex, label, opts){ function buildTile(kind, pubHex, label, opts){
@ -2036,6 +2056,19 @@ function renderVideoTile(kind, pubHex, stream, opts){
$(k.thumbContainer).appendChild(built.tile); $(k.thumbContainer).appendChild(built.tile);
entry = { tile: built.tile, video: built.video, label }; entry = { tile: built.tile, video: built.video, label };
k.store.set(pubHex, entry); k.store.set(pubHex, entry);
} else {
/* MSID-supplant retarget: when the same pubHex+kind republishes
* (publisher refreshed / sfu PC rebuilt), reassigning srcObject on
* the existing <video> hits the browser's autoplay policy as a
* mid-life srcObject swap and a fresh play() rejects with
* "fetching process for the media resource was aborted by the
* user agent at the user's request" — the tile stays paused on a
* black frame. Building a fresh <video> element resets that
* decision: muted-video autoplay applies again because the
* element has no prior gesture-consumed play() to defend against.
* Fox 2026-06-03 ("still black share camera from will" after a
* 16s supplant). */
entry.video = swapFreshVideoElement(entry.video);
} }
entry.video.srcObject = stream; entry.video.srcObject = stream;
try { try {
@ -2051,9 +2084,15 @@ function renderVideoTile(kind, pubHex, stream, opts){
logLine('err','play threw: '+e.message); logLine('err','play threw: '+e.message);
entry.tile.classList.add('needs-tap'); entry.tile.classList.add('needs-tap');
} }
/* if the same tile is currently spotlit, mirror the stream into the big tile */ /* if the same tile is currently spotlit, mirror the stream into the big
* tile — same supplant-vs-autoplay trap, same fix. */
if (spotlight && spotlight.kind === kind && spotlight.pubHex === pubHex && spotlight.video){ if (spotlight && spotlight.kind === kind && spotlight.pubHex === pubHex && spotlight.video){
spotlight.video = swapFreshVideoElement(spotlight.video);
spotlight.video.srcObject = stream; spotlight.video.srcObject = stream;
try {
const p = spotlight.video.play();
if (p && p.catch) p.catch(()=>{ spotlight.tile.classList.add('needs-tap'); });
} catch(_){}
} }
/* spotlight promotion rules: /* spotlight promotion rules:
* - no spotlight yet → first tile auto-promotes (anyone) * - no spotlight yet → first tile auto-promotes (anyone)
@ -4207,8 +4246,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> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-03</span><br> <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-03</span><br>
md5 <span class="stamp-md5">c874915d93ed4b044f8a9fd30e542d7c</span><br> md5 <span class="stamp-md5">86b88dea62be004921fe4e116bba90eb</span><br>
sha256 <span class="stamp-sha">e13bfc8b82cd58097e12ac00591b4f2a324f589aaae10f9b488bbe78e6cce82d</span><br> sha256 <span class="stamp-sha">3686823398c477768b1af4f1dbb86f19cf69923e58d1ad94d8ecb7669e613e00</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>