zebra-spaces: drop screen-resume flow — two flows for the same result is worse than one

The 'resume screen share' button needed the same user tap as just
clicking 'share screen' again, so it added complexity without buying
any UX. Camera silent-resume stays — that's a meaningfully different
flow (zero clicks if browser remembers the permission).
This commit is contained in:
Russell Ballestrini 2026-06-02 09:58:42 -04:00
parent abcbdc3568
commit 623c0a0b0c
No known key found for this signature in database

View file

@ -579,10 +579,6 @@ try {
<section id="sec-screen-share" class="hidden">
<h2>share</h2>
<div class="row hidden" id="row-resume-screen">
<button id="btn-resume-screen" class="invert">resume screen share</button>
<span class="note">browser needs your tap to recapture the screen — security requirement.</span>
</div>
<div class="row">
<button id="btn-screen-share" class="invert">share screen</button>
<button id="btn-screen-stop" class="hidden">stop sharing</button>
@ -663,22 +659,11 @@ const THEME_KEY = 'zebra-theme-v1';
* hard refresh auto-rejoins. Different tabs can sit in different
* spaces because sessionStorage doesn't bleed across tabs. */
const ACTIVE_CALL_KEY = 'zebra-spaces-active-call-v1';
/* what publishes were live before the refresh. Camera can attempt
* silent resume (browser usually remembers getUserMedia permission
* per-origin); screen CANNOT silently restart — getDisplayMedia
* requires a fresh user gesture every time (security boundary, by
* design). We surface a 'resume screen' button instead. */
const ACTIVE_CAM_KEY = 'zebra-spaces-active-cam-v1';
const ACTIVE_SCREEN_KEY = 'zebra-spaces-active-screen-v1';
function showResumeScreenPrompt(){
const r = document.getElementById('row-resume-screen');
if (r) r.classList.remove('hidden');
}
function hideResumeScreenPrompt(){
const r = document.getElementById('row-resume-screen');
if (r) r.classList.add('hidden');
}
/* camera resume only — screen-share recovery isn't possible silently
* (getDisplayMedia needs a fresh gesture) and offering a separate
* 'resume screen' button is the same friction as clicking 'share
* screen' again, so we just don't track screen state across reload. */
const ACTIVE_CAM_KEY = 'zebra-spaces-active-cam-v1';
/* theme toggle. The class is already applied pre-paint by the head
* script, so this just wires the click + keeps the button label in
@ -1146,8 +1131,6 @@ async function sfuPublishScreen(){
renderScreenTile(myKeys.pubHex, stream, { local: true });
$('btn-screen-share').classList.add('hidden');
$('btn-screen-stop').classList.remove('hidden');
try { sessionStorage.setItem(ACTIVE_SCREEN_KEY, '1'); } catch(_){}
hideResumeScreenPrompt();
}
async function sfuUnpublishScreen(){
if (!sfuScreenPC && !sfuScreenStream) return;
@ -1161,8 +1144,6 @@ async function sfuUnpublishScreen(){
$('btn-screen-share').classList.remove('hidden');
$('btn-screen-stop').classList.add('hidden');
logLine('', 'screen share stopped');
try { sessionStorage.removeItem(ACTIVE_SCREEN_KEY); } catch(_){}
hideResumeScreenPrompt();
}
/* ----- camera publish (kind=camera) ----- */
@ -1624,13 +1605,11 @@ async function handleSignal(raw){
* role is listener (was speaker before but room demoted us on
* rejoin), drop the flags so we don't endlessly prompt. */
if (canSpeak(myRole)){
/* camera: silent attempt — getUserMedia normally remembers
/* camera silent attempt — getUserMedia normally remembers
* per-origin permission, so the browser won't prompt. If it
* does prompt + the user denies, the flag self-clears via
* sfuPublishCamera's error path the next time it tries. */
let wantCam = false, wantScreen = false;
* does prompt + the user denies, the flag self-clears. */
let wantCam = false;
try { wantCam = sessionStorage.getItem(ACTIVE_CAM_KEY) === '1'; } catch(_){}
try { wantScreen = sessionStorage.getItem(ACTIVE_SCREEN_KEY) === '1'; } catch(_){}
if (wantCam){
setTimeout(() => {
sfuPublishCamera().catch(e => {
@ -1639,14 +1618,8 @@ async function handleSignal(raw){
});
}, 250);
}
if (wantScreen){
/* screen needs a user gesture per browser security — surface the
* resume button instead of trying to call getDisplayMedia silently */
showResumeScreenPrompt();
}
} else {
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
try { sessionStorage.removeItem(ACTIVE_SCREEN_KEY); } catch(_){}
}
renderShareUrl();
onRoleEntered();
@ -2070,12 +2043,6 @@ function renderRoom(){
$('btn-raise').addEventListener('click', () => { send({ type:'raise-hand' }); });
$('btn-lower').addEventListener('click', () => { send({ type:'lower-hand' }); });
$('btn-screen-share').addEventListener('click', () => { sfuPublishScreen().catch(e => logLine('err','screen share: '+e.message)); });
$('btn-resume-screen').addEventListener('click', () => {
/* user gesture — getDisplayMedia can fire now. On cancel we leave the
* prompt visible so they can try again; on success the publish path
* hides it via hideResumeScreenPrompt(). */
sfuPublishScreen().catch(e => logLine('err','screen resume: '+e.message));
});
$('btn-screen-stop').addEventListener('click', () => { sfuUnpublishScreen(); });
$('btn-camera-share').addEventListener('click', () => { sfuPublishCamera().catch(e => logLine('err','camera share: '+e.message)); });
$('btn-camera-stop').addEventListener('click', () => { sfuUnpublishCamera(); });
@ -2279,8 +2246,6 @@ function handleBlocked(source){
/* booted = clear auto-rejoin so a refresh doesn't immediately retry */
try { sessionStorage.removeItem(ACTIVE_CALL_KEY); } catch(_){}
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
try { sessionStorage.removeItem(ACTIVE_SCREEN_KEY); } catch(_){}
hideResumeScreenPrompt();
showNotice('You are blocked from this space.', 'warn');
logLine('err','blocked ('+source+') — stopping reconnects');
setStatus('blocked from this space','err');
@ -2410,8 +2375,6 @@ $('btn-leave').addEventListener('click', async () => {
/* explicit leave wipes the auto-rejoin state — user said 'out' */
try { sessionStorage.removeItem(ACTIVE_CALL_KEY); } catch(_){}
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
try { sessionStorage.removeItem(ACTIVE_SCREEN_KEY); } catch(_){}
hideResumeScreenPrompt();
if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; }
for (const u of [...peers.keys()]) tearPeer(u);
if (ws){ try { ws.close(); } catch(_){} ws = null; }
@ -2443,8 +2406,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">b89aba673e7e569e95639e8cfd0c67fb</span><br>
sha256 <span class="stamp-sha">f0aa1f22d67a6ebab6d37be08792d993dd100b9ea67962887136470ccbd6e159</span><br>
md5 <span class="stamp-md5">bcedfb60e6bcfaedb68a6e765e07147a</span><br>
sha256 <span class="stamp-sha">e05988eba4d8be995628c3875f7e3e1daca1f5fed54e571517ead8fcecbb288b</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>