Files
python-tdd/src/apps/voice/views.py

44 lines
1.7 KiB
Python
Raw Normal View History

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>
2026-05-30 01:41:30 -04:00
"""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 unmutere-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),
})