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:
parent
4ab41e9323
commit
abcbdc3568
1 changed files with 102 additions and 2 deletions
|
|
@ -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> · 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue