my-sea voice: persist mute across in-sea nav/refresh + 3-min muted auto-disconnect; fix first-connect glow/mute race — TDD

MUTE PERSISTENCE (user-spec 2026-05-30) — a voice mute used to vanish on any
in-sea navigation/refresh (the mesh tears down + auto-rejoins unmuted). Now the
mute is stamped server-side + re-applied on rejoin, with a 3-min muted →
auto-disconnect window:
- `User.voice_muted_at` (timestamp, not a bare bool, so the 3-min window anchors
  here) + migration. Per-user, not per-seat: the owner has no seat row, and a
  user is in ≤1 voice room at a time, so this uniformly covers owner + visitor.
- POST `/voice/mute` {muted} sets/clears it (new voice app views.py + urls.py,
  mounted at `voice/` in core/urls). my_sea + my_sea_visit pass the timestamp to
  `#id_voice_btn` as `data-voice-muted-at`.
- voice-mesh.js gains `setMuted(m)` (set vs. toggleMute's flip), honoured by
  join's post-getUserMedia `_applyMute`. burger-btn.js: a mute toggle POSTs the
  state + arms a client timer; the auto-rejoin re-applies the persisted mute +
  re-arms the timer from the stored timestamp (so the 3-min spans navigations,
  not resets); an elapsed window on rejoin auto-disconnects instead of rejoining;
  a fresh manual join clears any stale mute. On timeout: leave voice + clear.

FIRST-CONNECT GLOW/MUTE RACE (user-reported) — `setOnStateChange` pushes the
current state immediately on subscribe, and voice-glow.js often subscribes
MID-JOIN (getUserMedia pending → inCall=false). Its `setVoiceState` only ever
DELETED `voice.dataset.inCall` (never re-set it) — wiping the join-vs-mute flag
burger-btn.js had just set, so the next click re-joined instead of muting (which
also dropped the peer + killed the equalizer). Two fixes:
- voice-glow keeps `dataset.inCall` SYMMETRIC (set on true, delete on false), so
  the mid-join false is restored once the stream resolves → mute works on first
  connect.
- voice-glow subscribes reliably on AUTO-REJOIN too (no click to trigger its
  poll): voice-mesh.js dispatches `voiceroom:ready` on singleton creation +
  voice-glow listens, so the glow is mesh-driven (peer-count equalizer) after a
  refresh, not just the in-call-class fallback.

Coverage:
- ITs: VoiceMuteViewTest (login/405/invalid-json guards, stamp on true, clear on
  false, re-mute restamps, missing-key=false). voice+lyric 164 green.
- Jasmine: BurgerSpec mute persistence (muteRemainingMs window, rejoin re-mute,
  expired-window auto-disconnect, toggle-persists + 3-min fires, manual-join
  clears); VoiceGlowSpec dataset.inCall sync (sets on in-call, clears on not,
  restores after a mid-join false→true). All green.
- Live multi-party voice (mic/2-device) left to manual verification.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-30 01:41:30 -04:00
parent de4dcd7979
commit 668105aeeb
15 changed files with 545 additions and 7 deletions

View File

@@ -140,6 +140,49 @@
// BYE + the NVM-disconnect guard call this on an explicit leave.
window.mySeaVoiceForget = _forgetVoiceRoom;
// ── Mute persistence + 3-min auto-disconnect (user-spec 2026-05-30) ─────
// The mute is stored server-side (User.voice_muted_at) so it SURVIVES in-sea
// nav/refresh — the voice auto-rejoin re-applies it. A mute held for
// MUTE_MAX_MS auto-disconnects the user from voice (+ clears the field). The
// window anchors to the persisted timestamp, so it spans navigations instead
// of resetting on each page.
var MUTE_MAX_MS = 180000; // 3 minutes
var _muteTimer = null;
function _voiceCsrf() {
var m = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
return m ? decodeURIComponent(m[1]) : '';
}
// Persist (or clear) the caller's mute server-side. Best-effort — voice
// still works locally if the POST fails; only cross-nav persistence is lost.
function _persistMute(muted) {
try {
fetch('/voice/mute', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': _voiceCsrf() },
body: JSON.stringify({ muted: !!muted }),
}).catch(function () {});
} catch (e) {}
}
// ms remaining before the 3-min auto-disconnect, given the mute's start (ms)
// + now (ms). <= 0 means already elapsed. Pure — exposed for unit testing.
function muteRemainingMs(mutedAtMs, nowMs) {
return MUTE_MAX_MS - (nowMs - mutedAtMs);
}
window._muteRemainingMs = muteRemainingMs;
function _clearMuteTimer() {
if (_muteTimer) { clearTimeout(_muteTimer); _muteTimer = null; }
}
// Arm the auto-disconnect for the mute that began at `mutedAtMs` (ms). Fires
// `onFire` after the remaining window (next tick if already elapsed).
function _armMuteTimer(mutedAtMs, onFire) {
_clearMuteTimer();
var remaining = muteRemainingMs(mutedAtMs, Date.now());
_muteTimer = setTimeout(onFire, remaining > 0 ? remaining : 0);
}
// 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
@@ -165,6 +208,11 @@
var vbtn = document.getElementById('id_voice_btn');
if (!vbtn) return;
var roomId = vbtn.getAttribute('data-room-id');
// Persisted mute timestamp (server-rendered) — present iff the user was
// muted when this page loaded. Drives the auto-rejoin's mute + window.
var mutedAtAttr = vbtn.getAttribute('data-voice-muted-at');
var persistedMutedAtMs = mutedAtAttr ? Date.parse(mutedAtAttr) : NaN;
var hasPersistedMute = !isNaN(persistedMutedAtMs);
// VoiceRoom is lazy-loaded on first use (mesh injected on demand).
function withVoiceRoom(cb) {
@@ -175,6 +223,17 @@
document.head.appendChild(s);
}
// 3-min muted timeout fired: leave voice, forget the room (no rejoin),
// clear the muted UI + the server field.
function _muteAutoDisconnect() {
_clearMuteTimer();
if (window.VoiceRoom && window.VoiceRoom.leave) window.VoiceRoom.leave();
_forgetVoiceRoom();
_persistMute(false);
vbtn.classList.remove('muted', 'in-call');
delete vbtn.dataset.inCall;
}
function startCall() {
if (!window.VoiceRoom || vbtn.dataset.inCall) return;
vbtn.dataset.inCall = '1';
@@ -192,20 +251,42 @@
withVoiceRoom(function () {
if (!window.VoiceRoom) return;
if (!vbtn.dataset.inCall) {
// Fresh MANUAL join → start unmuted; clear any stale persisted
// mute (e.g. muted, then closed the tab, last session).
_clearMuteTimer();
_persistMute(false);
startCall();
} else {
var muted = window.VoiceRoom.toggleMute();
vbtn.classList.toggle('muted', muted);
_persistMute(muted);
if (muted) _armMuteTimer(Date.now(), _muteAutoDisconnect);
else _clearMuteTimer();
}
});
});
// 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).
// in-sea reload (GATE VIEW / NVM / draw nav) — CARRYING the mute state.
if (roomId && vbtn.classList.contains('active')
&& _rememberedVoiceRoom() === roomId) {
withVoiceRoom(startCall);
if (hasPersistedMute && muteRemainingMs(persistedMutedAtMs, Date.now()) <= 0) {
// The 3-min mute window already elapsed (muted across a long
// idle / nav) → honour the auto-disconnect: don't rejoin, clear.
_forgetVoiceRoom();
_persistMute(false);
vbtn.classList.remove('muted');
} else {
withVoiceRoom(function () {
if (hasPersistedMute && window.VoiceRoom.setMuted) {
window.VoiceRoom.setMuted(true); // honoured post-getUserMedia
vbtn.classList.add('muted');
_armMuteTimer(persistedMutedAtMs, _muteAutoDisconnect);
}
startCall();
});
}
}
}
window.bindVoiceBtn = bindVoiceBtn;