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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user