my-sea voice: persist the call across in-sea reloads via auto-rejoin; pngquant the RWS card back — TDD
Voice-persistence follow-up (user-spec item 6). Every my-sea navigation is a full page reload that kills the WebSocket + peer connections; true no-reload nav would need an SPA refactor of the heavily-tested draw IIFEs. Instead we auto-rejoin: bindVoiceBtn remembers the active room in sessionStorage on join and silently re-joins it on the next my-sea page if voice is still available there (mic permission persists for the session, so no prompt). Same user- visible result (a brief reconnect, not seamless) with no risk to the draw flows. - burger-btn.js: sessionStorage 'mysea-voice-room' remember/forget helpers + window.mySeaVoiceForget; bindVoiceBtn refactored to startCall()/withVoiceRoom() and auto-rejoins on bind when the remembered room === the active btn's room. A failed join (e.g. INSECURE_CONTEXT) forgets the room so it doesn't retry. - _my_sea_gear.html: the NVM-disconnect guard confirm + BYE forget the room (and leave the mesh) — an explicit leave shouldn't auto-rejoin. - BurgerSpec: +4 auto-rejoin specs (match / different-sea / inactive / remember + forget). 438 Jasmine specs green. Also (bundled, user's parallel work): pngquant the resaved RWS deck card back (tarot-rider-waite-smith-back.png) from 733KB truecolor+a to a 264KB 8-bit palette PNG, matching its companion card faces. Dimensions preserved (the rotated 401x694). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
vbtn.addEventListener('click', function () {
|
||||
if (!vbtn.classList.contains('active')) return; // → delegated flash
|
||||
var roomId = vbtn.getAttribute('data-room-id');
|
||||
if (!roomId) return;
|
||||
function act() {
|
||||
if (!window.VoiceRoom) return;
|
||||
if (!vbtn.dataset.inCall) {
|
||||
|
||||
// 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
|
||||
if (!roomId) return;
|
||||
withVoiceRoom(function () {
|
||||
if (!window.VoiceRoom) return;
|
||||
if (!vbtn.dataset.inCall) {
|
||||
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;
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 265 KiB |
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,11 @@ window.mySeaGuardedNav = window.mySeaGuardedNav || function (e, url) {
|
||||
window.showGuard(
|
||||
anchor,
|
||||
'Leave the Sea?<br>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 %}
|
||||
<form method="POST" action="{{ leave_url }}" style="display:contents">
|
||||
{% csrf_token %}
|
||||
<button type="submit" id="id_my_sea_bye_btn" class="btn btn-abandon">BYE</button>
|
||||
{# 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. #}
|
||||
<button type="submit" id="id_my_sea_bye_btn" class="btn btn-abandon"
|
||||
onclick="if(window.mySeaVoiceForget)window.mySeaVoiceForget()">BYE</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user