diff --git a/src/apps/epic/static/apps/epic/burger-btn.js b/src/apps/epic/static/apps/epic/burger-btn.js index c76943e..cd46c01 100644 --- a/src/apps/epic/static/apps/epic/burger-btn.js +++ b/src/apps/epic/static/apps/epic/burger-btn.js @@ -120,6 +120,26 @@ // INACTIVE click is left to the delegated fan handler's 2-pulse flash. // No stopPropagation on active — the delegated handler then closes the // fan (its existing .active behaviour). + // Voice persistence across my_sea reloads (2026-05-29): we remember the + // active room in sessionStorage so the next my_sea page silently re-joins + // the mesh (mic permission persists for the session → no prompt). True + // no-reload nav would need an SPA refactor of the draw IIFEs; this gets the + // same user-visible result (a brief reconnect, not seamless) with no risk + // to those flows. The flag is cleared only on an EXPLICIT leave (BYE / + // NVM-disconnect guard) or a failed join, never on an in-my_sea reload. + var VOICE_ROOM_KEY = 'mysea-voice-room'; + function _rememberVoiceRoom(id) { + try { sessionStorage.setItem(VOICE_ROOM_KEY, id); } catch (e) {} + } + function _forgetVoiceRoom() { + try { sessionStorage.removeItem(VOICE_ROOM_KEY); } catch (e) {} + } + function _rememberedVoiceRoom() { + try { return sessionStorage.getItem(VOICE_ROOM_KEY); } catch (e) { return null; } + } + // BYE + the NVM-disconnect guard call this on an explicit leave. + window.mySeaVoiceForget = _forgetVoiceRoom; + // Surface a join failure to the user instead of failing silently — most // often the secure-context block (INSECURE_CONTEXT) when the dev server is // reached over plain HTTP from a phone. Prefers the Brief banner; falls @@ -127,6 +147,7 @@ function _voiceJoinFailed(vbtn, e) { vbtn.classList.remove('in-call'); delete vbtn.dataset.inCall; // let the next click retry the join + _forgetVoiceRoom(); // don't auto-rejoin a context that can't talk var msg = (e && e.code === 'INSECURE_CONTEXT') ? 'Voice needs HTTPS (or localhost) — your browser blocked the mic here.' : 'Couldn’t start voice — mic unavailable or permission denied.'; @@ -143,33 +164,49 @@ function bindVoiceBtn() { var vbtn = document.getElementById('id_voice_btn'); if (!vbtn) return; + var roomId = vbtn.getAttribute('data-room-id'); + + // VoiceRoom is lazy-loaded on first use (mesh injected on demand). + function withVoiceRoom(cb) { + if (window.VoiceRoom) { cb(); return; } + var s = document.createElement('script'); + s.src = '/static/apps/voice/voice-mesh.js'; + s.onload = cb; + document.head.appendChild(s); + } + + function startCall() { + if (!window.VoiceRoom || vbtn.dataset.inCall) return; + vbtn.dataset.inCall = '1'; + vbtn.classList.add('in-call'); + _rememberVoiceRoom(roomId); + var p = window.VoiceRoom.join(roomId); + if (p && typeof p.catch === 'function') { + p.catch(function (e) { _voiceJoinFailed(vbtn, e); }); + } + } + vbtn.addEventListener('click', function () { if (!vbtn.classList.contains('active')) return; // → delegated flash - var roomId = vbtn.getAttribute('data-room-id'); if (!roomId) return; - function act() { + withVoiceRoom(function () { if (!window.VoiceRoom) return; if (!vbtn.dataset.inCall) { - vbtn.dataset.inCall = '1'; - vbtn.classList.add('in-call'); - var p = window.VoiceRoom.join(roomId); - if (p && typeof p.catch === 'function') { - p.catch(function (e) { _voiceJoinFailed(vbtn, e); }); - } + startCall(); } else { var muted = window.VoiceRoom.toggleMute(); vbtn.classList.toggle('muted', muted); } - } - if (window.VoiceRoom) { - act(); - } else { - var s = document.createElement('script'); - s.src = '/static/apps/voice/voice-mesh.js'; - s.onload = act; - document.head.appendChild(s); - } + }); }); + + // Auto-rejoin: were we in THIS room before the navigation, and is voice + // still available on this page? Silently re-join so the call survives an + // in-my_sea reload (GATE VIEW / NVM / draw nav). + if (roomId && vbtn.classList.contains('active') + && _rememberedVoiceRoom() === roomId) { + withVoiceRoom(startCall); + } } window.bindVoiceBtn = bindVoiceBtn; diff --git a/src/apps/epic/static/apps/epic/images/cards-faces/english/rider-waite-smith/tarot-rider-waite-smith-back.png b/src/apps/epic/static/apps/epic/images/cards-faces/english/rider-waite-smith/tarot-rider-waite-smith-back.png index 668b40f..169f4ea 100644 Binary files a/src/apps/epic/static/apps/epic/images/cards-faces/english/rider-waite-smith/tarot-rider-waite-smith-back.png and b/src/apps/epic/static/apps/epic/images/cards-faces/english/rider-waite-smith/tarot-rider-waite-smith-back.png differ diff --git a/src/static/tests/BurgerSpec.js b/src/static/tests/BurgerSpec.js index 688d552..a8b9cc1 100644 --- a/src/static/tests/BurgerSpec.js +++ b/src/static/tests/BurgerSpec.js @@ -249,3 +249,57 @@ describe("Burger", () => { }); }); }); + +// Voice persistence across my_sea reloads — bindVoiceBtn remembers the active +// room in sessionStorage + silently re-joins it on the next page if voice is +// still available (2026-05-29). +describe("voice auto-rejoin", () => { + let vbtn, origVR, joinSpy; + + beforeEach(() => { + try { sessionStorage.removeItem("mysea-voice-room"); } catch (e) {} + vbtn = document.createElement("button"); + vbtn.id = "id_voice_btn"; + vbtn.classList.add("active"); + vbtn.setAttribute("data-room-id", "mysea-abc"); + document.body.appendChild(vbtn); + origVR = window.VoiceRoom; + joinSpy = jasmine.createSpy("join").and.returnValue(Promise.resolve()); + window.VoiceRoom = { join: joinSpy }; + }); + + afterEach(() => { + window.VoiceRoom = origVR; + vbtn.remove(); + try { sessionStorage.removeItem("mysea-voice-room"); } catch (e) {} + }); + + it("rejoins when the remembered room matches the active voice btn", () => { + sessionStorage.setItem("mysea-voice-room", "mysea-abc"); + bindVoiceBtn(); + expect(joinSpy).toHaveBeenCalledWith("mysea-abc"); + }); + + it("does NOT rejoin when the remembered room is a different sea", () => { + sessionStorage.setItem("mysea-voice-room", "mysea-other"); + bindVoiceBtn(); + expect(joinSpy).not.toHaveBeenCalled(); + }); + + it("does NOT rejoin when voice is unavailable (btn inactive)", () => { + vbtn.classList.remove("active"); + sessionStorage.setItem("mysea-voice-room", "mysea-abc"); + bindVoiceBtn(); + expect(joinSpy).not.toHaveBeenCalled(); + }); + + it("remembers the room on a manual join; mySeaVoiceForget clears it", () => { + bindVoiceBtn(); + expect(joinSpy).not.toHaveBeenCalled(); // nothing remembered yet + vbtn.click(); + expect(joinSpy).toHaveBeenCalledWith("mysea-abc"); + expect(sessionStorage.getItem("mysea-voice-room")).toBe("mysea-abc"); + window.mySeaVoiceForget(); + expect(sessionStorage.getItem("mysea-voice-room")).toBeNull(); + }); +}); diff --git a/src/static_src/tests/BurgerSpec.js b/src/static_src/tests/BurgerSpec.js index 688d552..a8b9cc1 100644 --- a/src/static_src/tests/BurgerSpec.js +++ b/src/static_src/tests/BurgerSpec.js @@ -249,3 +249,57 @@ describe("Burger", () => { }); }); }); + +// Voice persistence across my_sea reloads — bindVoiceBtn remembers the active +// room in sessionStorage + silently re-joins it on the next page if voice is +// still available (2026-05-29). +describe("voice auto-rejoin", () => { + let vbtn, origVR, joinSpy; + + beforeEach(() => { + try { sessionStorage.removeItem("mysea-voice-room"); } catch (e) {} + vbtn = document.createElement("button"); + vbtn.id = "id_voice_btn"; + vbtn.classList.add("active"); + vbtn.setAttribute("data-room-id", "mysea-abc"); + document.body.appendChild(vbtn); + origVR = window.VoiceRoom; + joinSpy = jasmine.createSpy("join").and.returnValue(Promise.resolve()); + window.VoiceRoom = { join: joinSpy }; + }); + + afterEach(() => { + window.VoiceRoom = origVR; + vbtn.remove(); + try { sessionStorage.removeItem("mysea-voice-room"); } catch (e) {} + }); + + it("rejoins when the remembered room matches the active voice btn", () => { + sessionStorage.setItem("mysea-voice-room", "mysea-abc"); + bindVoiceBtn(); + expect(joinSpy).toHaveBeenCalledWith("mysea-abc"); + }); + + it("does NOT rejoin when the remembered room is a different sea", () => { + sessionStorage.setItem("mysea-voice-room", "mysea-other"); + bindVoiceBtn(); + expect(joinSpy).not.toHaveBeenCalled(); + }); + + it("does NOT rejoin when voice is unavailable (btn inactive)", () => { + vbtn.classList.remove("active"); + sessionStorage.setItem("mysea-voice-room", "mysea-abc"); + bindVoiceBtn(); + expect(joinSpy).not.toHaveBeenCalled(); + }); + + it("remembers the room on a manual join; mySeaVoiceForget clears it", () => { + bindVoiceBtn(); + expect(joinSpy).not.toHaveBeenCalled(); // nothing remembered yet + vbtn.click(); + expect(joinSpy).toHaveBeenCalledWith("mysea-abc"); + expect(sessionStorage.getItem("mysea-voice-room")).toBe("mysea-abc"); + window.mySeaVoiceForget(); + expect(sessionStorage.getItem("mysea-voice-room")).toBeNull(); + }); +}); diff --git a/src/templates/apps/gameboard/_partials/_my_sea_gear.html b/src/templates/apps/gameboard/_partials/_my_sea_gear.html index 18d292e..45a9b88 100644 --- a/src/templates/apps/gameboard/_partials/_my_sea_gear.html +++ b/src/templates/apps/gameboard/_partials/_my_sea_gear.html @@ -25,7 +25,11 @@ window.mySeaGuardedNav = window.mySeaGuardedNav || function (e, url) { window.showGuard( anchor, 'Leave the Sea?
You’ll disconnect from voice.', - function () { window.location.href = url; }, // confirm + function () { // confirm = leave + if (window.mySeaVoiceForget) window.mySeaVoiceForget(); + if (window.VoiceRoom && window.VoiceRoom.leave) window.VoiceRoom.leave(); + window.location.href = url; + }, null, // dismiss = stay { yesLabel: 'NVM' } ); @@ -42,7 +46,10 @@ window.mySeaGuardedNav = window.mySeaGuardedNav || function (e, url) { {% if leave_url %}
{% csrf_token %} - + {# BYE is an explicit leave — forget the room so the destination page #} + {# (and any return to my_sea) doesn't auto-rejoin the dropped call. #} +
{% endif %}