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>
44 lines
1.7 KiB
Python
44 lines
1.7 KiB
Python
"""HTTP views for the voice app.
|
|
|
|
The WebRTC mesh itself is signalled over WebSocket (see consumers.py) + holds
|
|
no server state. The only HTTP surface is mute-state persistence: a my-sea voice
|
|
mute must survive in-sea navigation/refresh (which tear down + re-open the mesh),
|
|
so the mute is stamped on `User.voice_muted_at` and the client re-applies it on
|
|
the voice auto-rejoin. The timestamp also anchors the 3-min "muted too long →
|
|
auto-disconnect" window the client enforces.
|
|
"""
|
|
|
|
import json
|
|
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.http import JsonResponse
|
|
from django.utils import timezone
|
|
from django.views.decorators.http import require_POST
|
|
|
|
|
|
@login_required(login_url="/")
|
|
@require_POST
|
|
def voice_mute(request):
|
|
"""Persist the caller's voice mute state on `User.voice_muted_at`.
|
|
|
|
Body: JSON `{"muted": <bool>}`.
|
|
- muted=True → stamp `now` (anchors the 3-min auto-disconnect window).
|
|
- muted=False → clear (on unmute, a fresh manual join, or the 3-min
|
|
timeout's leave).
|
|
|
|
Idempotent — re-muting just re-stamps `now` (restarting the 3-min window),
|
|
which is the intended behaviour for an unmute→re-mute. Returns
|
|
`{ok, muted_at}` (ISO 8601 or null)."""
|
|
try:
|
|
payload = json.loads(request.body.decode("utf-8") or "{}")
|
|
except json.JSONDecodeError:
|
|
return JsonResponse({"error": "invalid_json"}, status=400)
|
|
muted = bool(payload.get("muted"))
|
|
request.user.voice_muted_at = timezone.now() if muted else None
|
|
request.user.save(update_fields=["voice_muted_at"])
|
|
return JsonResponse({
|
|
"ok": True,
|
|
"muted_at": (request.user.voice_muted_at.isoformat()
|
|
if request.user.voice_muted_at else None),
|
|
})
|