zebra-spaces: tap-to-play overlay + HTML muted attribute — fixes Firefox Android autoplay

Firefox Android (and other strict mobile browsers) check the HTML 'muted'
attribute, not the IDL .muted property, when deciding whether MediaStream
<video> is autoplay-eligible. Setting only the property left the video
ineligible, so play() rejected silently and the user saw a black tile.

Three changes:
- set the autoplay/playsinline/muted attributes alongside the properties
  so every UA's autoplay heuristic agrees the element is eligible
- log play() rejections instead of swallowing them — silent failures hid
  this from us until now
- when play() does reject, show a 'tap to play' overlay that covers the
  video area (but not the meta bar); tapping counts as the gesture and
  the retry succeeds
This commit is contained in:
Russell Ballestrini 2026-06-01 20:20:10 -04:00
parent 5affebead5
commit 6fbeade12d
No known key found for this signature in database

View file

@ -63,7 +63,7 @@
#screens { display: grid; grid-template-columns: 1fr; gap: 0.75rem; }
.screen-tile {
border: 1px solid #000; background: #000; padding: 0;
display: flex; flex-direction: column;
display: flex; flex-direction: column; position: relative;
}
.screen-tile video {
width: 100%; height: auto; max-height: 92vh; display: block; background: #000;
@ -72,6 +72,17 @@
background: #111; color: #ddd; font-size: 0.7rem; padding: 0.3rem 0.5rem;
display: flex; justify-content: space-between;
}
/* Firefox Android rejects MediaStream <video> autoplay even when muted.
* When play() rejects we surface a tap-to-play overlay over the video
* area only (the meta bar stays clickable); the click counts as the
* gesture so the retry play() succeeds. */
.screen-tile .tap-play {
position: absolute; left: 0; right: 0; top: 0; bottom: 1.6rem;
display: none; align-items: center; justify-content: center;
background: rgba(0,0,0,0.65); color: #fff; cursor: pointer;
font-family: monospace; font-size: 1rem; user-select: none;
}
.screen-tile.needs-tap .tap-play { display: flex; }
/* sec-screens visible → push everything else in the timeline column down or hide */
.timeline:has(> #sec-screens:not(.hidden)) > .game-tabs,
.timeline:has(> #sec-screens:not(.hidden)) > #game-frame { display: none; }
@ -613,11 +624,20 @@ function renderScreenTile(pubHex, stream, opts){
if (!entry){
const tile = document.createElement('div'); tile.className = 'screen-tile';
const video = document.createElement('video');
/* mobile autoplay policy refuses an unmuted <video> with audio without
* a user gesture — the whole element stays paused, no pixels render.
* Start muted on remote tiles too; an explicit unmute button below
* lets the user opt into audio with a click (counts as the gesture). */
/* set BOTH the HTML attributes and the IDL properties — Firefox Android
* checks the attribute when deciding autoplay eligibility, not just the
* .muted property. Without the attribute, MediaStream-backed video
* stays paused even though it's logically muted. */
video.setAttribute('autoplay', '');
video.setAttribute('playsinline', '');
video.setAttribute('muted', '');
video.autoplay = true; video.playsInline = true; video.muted = true;
const tap = document.createElement('div'); tap.className = 'tap-play';
tap.textContent = 'tap to play';
tap.onclick = () => {
try { video.play().then(() => { tile.classList.remove('needs-tap'); })
.catch(e => logLine('err','play after tap: '+e.message)); } catch(_){}
};
const meta = document.createElement('div'); meta.className = 'screen-meta';
const who = document.createElement('span'); who.textContent = 'screen: '+label;
const ctl = document.createElement('span');
@ -629,8 +649,9 @@ function renderScreenTile(pubHex, stream, opts){
unmute.onclick = () => {
const turnOn = video.muted;
video.muted = !turnOn;
if (video.muted) video.setAttribute('muted', ''); else video.removeAttribute('muted');
unmute.textContent = video.muted ? 'unmute audio' : 'mute audio';
try { video.play().catch(()=>{}); } catch(_){}
try { video.play().catch(e => logLine('err','play after unmute: '+e.message)); } catch(_){}
};
ctl.appendChild(unmute);
}
@ -638,13 +659,25 @@ function renderScreenTile(pubHex, stream, opts){
pop.textContent = 'fullscreen'; pop.onclick = () => video.requestFullscreen && video.requestFullscreen().catch(()=>{});
ctl.appendChild(pop);
meta.appendChild(who); meta.appendChild(ctl);
tile.appendChild(video); tile.appendChild(meta);
tile.appendChild(video); tile.appendChild(tap); tile.appendChild(meta);
$('screens').appendChild(tile);
entry = { tile, video, label };
screenVideos.set(pubHex, entry);
}
entry.video.srcObject = stream;
try { const p = entry.video.play(); if (p && p.catch) p.catch(()=>{}); } catch(_){}
try {
const p = entry.video.play();
if (p && p.then){
p.then(() => { entry.tile.classList.remove('needs-tap'); })
.catch(e => {
logLine('err','autoplay blocked: '+e.message+' — tap the tile to play');
entry.tile.classList.add('needs-tap');
});
}
} catch(e){
logLine('err','play threw: '+e.message);
entry.tile.classList.add('needs-tap');
}
$('sec-screens').classList.remove('hidden');
logLine('', 'screen share: '+(local?'local preview ':'receiving ')+label);
}
@ -1660,8 +1693,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> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-02</span><br>
md5 <span class="stamp-md5">0f70c2982b75f949b39a25acdbeed454</span><br>
sha256 <span class="stamp-sha">d746c549cf08f71203763b4e4845b2e1589acfc730185819ff848415d1cce059</span><br>
md5 <span class="stamp-md5">dcf58a2cc3ae320f3d73da16ea79471b</span><br>
sha256 <span class="stamp-sha">5bc64c4b97bf6a3426ca2b682da978c8950689e198b8b9c3fab8f554ede05733</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>