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 %}