+
+ browser needs your tap to recapture the screen — security requirement.
+
@@ -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');