zebra-spaces: auto-rejoin call after hard refresh + camera silent resume + screen resume prompt

sessionStorage (per-tab, not localStorage — tabs A and B can sit in
different spaces and don't fight over a shared slot) carries three
keys across a reload:
  zebra-spaces-active-call-v1   rendezvous code of the active space
  zebra-spaces-active-cam-v1    '1' if camera was publishing
  zebra-spaces-active-screen-v1 '1' if screen was publishing

Flow:
- joinSpace welcome handler writes the code; on leave / boot / role
  demotion the keys are cleared
- on init, after identity restore + URL ?code= handling, autoRejoin()
  checks sessionStorage. If a code is saved and we have a handle, it
  populates the rdv-code field and triggers joinSpace(). URL ?code=
  takes priority (a fresh share-URL navigation overrides).
- after welcome lands, if cam flag is set, sfuPublishCamera() runs
  silently — browsers usually remember per-origin getUserMedia perms.
  On failure the flag self-clears.
- screen cannot be silently resumed (getDisplayMedia requires a fresh
  user gesture every call — security). A 'resume screen share' button
  surfaces in the share section instead; clicking it counts as the
  gesture and re-shares.

Multi-tab safe because sessionStorage doesn't bleed across tabs.
This commit is contained in:
Russell Ballestrini 2026-06-02 09:52:31 -04:00
parent 4ab41e9323
commit abcbdc3568
No known key found for this signature in database

View file

@ -579,6 +579,10 @@ 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>
@ -655,6 +659,26 @@ const MUSIC_MODE_KEY = 'zebra-spaces-music-mode-v1';
const MIC_DEV_KEY = 'zebra-spaces-mic-device-v1';
const CAM_DEV_KEY = 'zebra-spaces-cam-device-v1';
const THEME_KEY = 'zebra-theme-v1';
/* sessionStorage (per-tab) — tracks which call this tab is in so a
* 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');
}
/* theme toggle. The class is already applied pre-paint by the head
* script, so this just wires the click + keeps the button label in
@ -1122,6 +1146,8 @@ 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;
@ -1135,6 +1161,8 @@ 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) ----- */
@ -1178,6 +1206,7 @@ async function sfuPublishCamera(){
renderCameraTile(myKeys.pubHex, stream, { local: true });
$('btn-camera-share').classList.add('hidden');
$('btn-camera-stop').classList.remove('hidden');
try { sessionStorage.setItem(ACTIVE_CAM_KEY, '1'); } catch(_){}
}
async function sfuUnpublishCamera(){
if (!sfuCameraPC && !sfuCameraStream) return;
@ -1191,6 +1220,7 @@ async function sfuUnpublishCamera(){
$('btn-camera-share').classList.remove('hidden');
$('btn-camera-stop').classList.add('hidden');
logLine('', 'camera off');
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
}
async function sfuUnpublish(){
@ -1586,6 +1616,38 @@ async function handleSignal(raw){
$('dot-call').className='dot ok';
$('btn-leave').disabled = false;
$('sec-room').classList.remove('hidden');
/* remember which space this tab is in so a hard refresh auto-rejoins.
* sessionStorage is per-tab so tab A in space X + tab B in space Y
* stay independent and clear cleanly on tab close. */
try { sessionStorage.setItem(ACTIVE_CALL_KEY, $('rdv-code').value.trim()); } catch(_){}
/* publish-resume: only meaningful for roles that can speak. If our
* 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
* 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;
try { wantCam = sessionStorage.getItem(ACTIVE_CAM_KEY) === '1'; } catch(_){}
try { wantScreen = sessionStorage.getItem(ACTIVE_SCREEN_KEY) === '1'; } catch(_){}
if (wantCam){
setTimeout(() => {
sfuPublishCamera().catch(e => {
logLine('err', 'camera resume failed: ' + e.message);
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
});
}, 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();
renderRoom();
@ -2008,6 +2070,12 @@ 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(); });
@ -2208,6 +2276,11 @@ function handleBlocked(source){
if (blocked) return;
blocked = true;
wantConnected = false;
/* 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');
@ -2301,6 +2374,28 @@ $('btn-copy-share').addEventListener('click', async () => {
if (c) $('rdv-code').value = c;
}
/* auto-rejoin after a hard refresh — sessionStorage carried the rendezvous
* code through the reload. Only fires when we have a handle AND the code,
* and the input hasn't been overridden by ?code= (which still wins so a
* fresh-share URL drops you into the linked space). Multi-tab safe because
* sessionStorage is per-tab. */
(function autoRejoin(){
let saved = '';
try { saved = sessionStorage.getItem(ACTIVE_CALL_KEY) || ''; } catch(_){}
if (!saved) return;
if (!myHandle){ logLine('', 'auto-rejoin pending: no handle yet'); return; }
/* ?code= in URL takes priority over the saved session — if they differ,
* trust the URL (user intentionally navigated to a different space) */
const urlCode = new URLSearchParams(location.search).get('code');
if (urlCode && urlCode !== saved){
try { sessionStorage.removeItem(ACTIVE_CALL_KEY); } catch(_){}
return;
}
$('rdv-code').value = saved;
logLine('', 'auto-rejoining last space …');
setTimeout(() => { joinSpace().catch(e => logLine('err', 'auto-rejoin failed: ' + e.message)); }, 100);
})();
/* game-tabs: click a button → swap iframe src + mark active. data-src is
* the only source of truth so new games just need a button. */
$('game-tabs').addEventListener('click', (ev) => {
@ -2312,6 +2407,11 @@ $('game-tabs').addEventListener('click', (ev) => {
});
$('btn-leave').addEventListener('click', async () => {
wantConnected = false;
/* 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; }
@ -2343,8 +2443,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">497177716ecd141a36ec3b5765a55457</span><br>
sha256 <span class="stamp-sha">2718fbf7796bc90da2fbb88bc5eb226cbb8c0f116c023d1292cdaf681fd48274</span><br>
md5 <span class="stamp-md5">b89aba673e7e569e95639e8cfd0c67fb</span><br>
sha256 <span class="stamp-sha">f0aa1f22d67a6ebab6d37be08792d993dd100b9ea67962887136470ccbd6e159</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>