my-sea voice: voice-btn glow/pulse state machine (sea-precedence, pulse-while-alone, 2x on 2nd party) — TDD
Phase 3 of the my-sea voice batch (user-spec 2026-05-29). A --quaUser/--ninUser
glow + pulse machine for the burger btn + its voice sub-btn, driven by the
voice sub-btn availability (voice_active) + the live mesh state.
- voice-mesh.js: VoiceRoom gains a state-change hook — setOnStateChange(cb) +
peerCount() + _notify({inCall, peerCount, muted}), fired on join, every peer
add/drop, mute toggle, and teardown. No behaviour change without a subscriber
(VoiceMeshSpec stays green).
- voice-glow.js (new): the glow machine. PRE-JOIN nudge — burger glows when the
fan is closed (sea draw-nudge keeps burger precedence; voice reclaims it once
the sea glow clears), voice sub-btn glows when the fan opens. LIVE — the glow
PULSES on whichever surface shows (voice sub-btn fan-open, burger fan-closed):
base 2s cadence while alone, doubled (.voice-pulse--fast) once a 2nd party
connects (equalizer stand-in; a true volume-reactive equalizer is a live-only
enhancement). Class writes are reconciled (idempotent) so the burger-class
MutationObserver doesn't feed back on itself.
- _burger.scss: .voice-glow + @keyframes voice-pulse + .voice-pulse(--fast).
- loaded on my_sea.html + my_sea_visit.html (after burger-btn.js).
- VoiceGlowSpec.js (18 specs) + registered in SpecRunner; MySeaSeatsSpec flare
window updated 1.5s → 2s (Phase 2 bump). 428 Jasmine specs green.
Live-verify on staging: the actual glow colours/cadence + the equalizer
upgrade (item 5) and disconnect states (item 7) land in later phases.
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:
126
src/apps/voice/static/apps/voice/voice-glow.js
Normal file
126
src/apps/voice/static/apps/voice/voice-glow.js
Normal file
@@ -0,0 +1,126 @@
|
||||
// voice-glow.js — the my-sea voice-affordance glow/pulse state machine (Phase 3
|
||||
// of the my-sea invite/voice batch, user-spec 2026-05-29). Drives --quaUser /
|
||||
// --ninUser glow + pulse on the burger btn + its voice sub-btn off two signals:
|
||||
// • the voice sub-btn's availability (.active, server-rendered voice_active),
|
||||
// • the live mesh state (VoiceRoom.setOnStateChange → {inCall, peerCount}).
|
||||
//
|
||||
// Rules (mirrors the sea-btn glow handoff, but for voice):
|
||||
// PRE-JOIN nudge (voice available, mic not yet live):
|
||||
// • burger open → the voice sub-btn glows (steady --quaUser). The sea-btn
|
||||
// draw nudge keeps burger precedence; voice is the second-place reveal.
|
||||
// • burger closed → the burger glows, but ONLY once the sea nudge has
|
||||
// cleared (sea takes precedence on the burger).
|
||||
// LIVE (mic in use):
|
||||
// • the glow PULSES (ease in/out). burger open → the voice sub-btn pulses;
|
||||
// fan collapsed → the burger pulses instead, so the mic-live cue rides
|
||||
// whichever surface is showing.
|
||||
// • alone in the room → base 2s cadence (.voice-pulse). A 2nd+ party →
|
||||
// doubled cadence (.voice-pulse--fast) — an equalizer stand-in (a true
|
||||
// volume-reactive equalizer is a live-only enhancement).
|
||||
//
|
||||
// `bindVoiceGlow()` reads the DOM, wires the observers + mesh subscription, and
|
||||
// returns { render, setVoiceState, destroy } for tests / re-binds. Class writes
|
||||
// are reconciled (idempotent) so the burger-class MutationObserver doesn't feed
|
||||
// back into itself.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var OUR_CLASSES = ['voice-glow', 'voice-pulse', 'voice-pulse--fast'];
|
||||
|
||||
function bindVoiceGlow() {
|
||||
var burger = document.getElementById('id_burger_btn');
|
||||
var voice = document.getElementById('id_voice_btn');
|
||||
var sea = document.getElementById('id_sea_btn');
|
||||
if (!burger || !voice) return null;
|
||||
|
||||
var inCall = false;
|
||||
var peerCount = 0;
|
||||
var observers = [];
|
||||
|
||||
// Reconcile el's OUR_CLASSES to exactly `want` — only mutate on an
|
||||
// actual diff so the MutationObserver below doesn't loop on our own
|
||||
// writes.
|
||||
function _apply(el, want) {
|
||||
OUR_CLASSES.forEach(function (c) {
|
||||
var has = el.classList.contains(c);
|
||||
var need = want.indexOf(c) !== -1;
|
||||
if (need && !has) el.classList.add(c);
|
||||
else if (!need && has) el.classList.remove(c);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
var voiceWant = [];
|
||||
var burgerWant = [];
|
||||
|
||||
if (voice.classList.contains('active')) { // voice available
|
||||
var burgerOpen = burger.classList.contains('active');
|
||||
var live = inCall || voice.classList.contains('in-call');
|
||||
if (live) {
|
||||
var pulse = peerCount > 0 ? 'voice-pulse--fast' : 'voice-pulse';
|
||||
(burgerOpen ? voiceWant : burgerWant).push('voice-glow', pulse);
|
||||
} else {
|
||||
var seaGlow = (sea && sea.classList.contains('glow-handoff'))
|
||||
|| burger.classList.contains('glow-handoff');
|
||||
if (burgerOpen) voiceWant.push('voice-glow');
|
||||
else if (!seaGlow) burgerWant.push('voice-glow');
|
||||
}
|
||||
}
|
||||
|
||||
_apply(voice, voiceWant);
|
||||
_apply(burger, burgerWant);
|
||||
}
|
||||
|
||||
function setVoiceState(st) {
|
||||
st = st || {};
|
||||
inCall = !!st.inCall;
|
||||
peerCount = st.peerCount || 0;
|
||||
render();
|
||||
}
|
||||
|
||||
// Burger open/close + sea-btn glow handoff are both class changes.
|
||||
[burger, sea].forEach(function (el) {
|
||||
if (!el) return;
|
||||
var mo = new MutationObserver(render);
|
||||
mo.observe(el, { attributes: true, attributeFilter: ['class'] });
|
||||
observers.push(mo);
|
||||
});
|
||||
|
||||
// Subscribe to the mesh. VoiceRoom may load lazily on the first voice
|
||||
// click (burger-btn.js injects voice-mesh.js), so retry briefly after
|
||||
// a click until the singleton appears.
|
||||
function subscribe() {
|
||||
if (window.VoiceRoom && window.VoiceRoom.setOnStateChange) {
|
||||
window.VoiceRoom.setOnStateChange(setVoiceState);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!subscribe()) {
|
||||
voice.addEventListener('click', function () {
|
||||
var tries = 0;
|
||||
var iv = setInterval(function () {
|
||||
if (subscribe() || ++tries > 40) clearInterval(iv);
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
render();
|
||||
return {
|
||||
render: render,
|
||||
setVoiceState: setVoiceState,
|
||||
destroy: function () {
|
||||
observers.forEach(function (o) { o.disconnect(); });
|
||||
observers = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
window.bindVoiceGlow = bindVoiceGlow;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bindVoiceGlow);
|
||||
} else {
|
||||
bindVoiceGlow();
|
||||
}
|
||||
}());
|
||||
@@ -40,8 +40,32 @@
|
||||
this.iceServers = ICE_FALLBACK;
|
||||
this.selfId = null;
|
||||
this.muted = false;
|
||||
// Subscriber for connection-state changes (voice-glow.js drives the
|
||||
// burger/voice-btn glow machine off it). Fired on join, every peer
|
||||
// add/drop, mute toggle, and teardown.
|
||||
this._onState = null;
|
||||
}
|
||||
|
||||
// ── State-change notification (drives the glow machine) ───────────────────
|
||||
|
||||
VoiceRoom.prototype.peerCount = function () {
|
||||
return Object.keys(this.peers).length;
|
||||
};
|
||||
|
||||
VoiceRoom.prototype.setOnStateChange = function (cb) {
|
||||
this._onState = cb;
|
||||
this._notify(); // push current state immediately on subscribe
|
||||
};
|
||||
|
||||
VoiceRoom.prototype._notify = function () {
|
||||
if (typeof this._onState !== 'function') return;
|
||||
this._onState({
|
||||
inCall: !!this.localStream,
|
||||
peerCount: this.peerCount(),
|
||||
muted: this.muted,
|
||||
});
|
||||
};
|
||||
|
||||
VoiceRoom.prototype._fetchTurn = function () {
|
||||
return fetch('/api/voice/turn-credentials/', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
@@ -73,6 +97,7 @@
|
||||
el.srcObject = e.streams[0];
|
||||
};
|
||||
this.peers[peerId] = pc;
|
||||
this._notify(); // a peer joined the mesh
|
||||
return pc;
|
||||
};
|
||||
|
||||
@@ -105,6 +130,7 @@
|
||||
if (pc) { try { pc.close(); } catch (e) {} delete this.peers[peerId]; }
|
||||
var el = document.getElementById('voice-audio-' + peerId);
|
||||
if (el && el.parentNode) el.parentNode.removeChild(el);
|
||||
this._notify(); // a peer left the mesh
|
||||
};
|
||||
|
||||
VoiceRoom.prototype._send = function (obj) {
|
||||
@@ -158,6 +184,7 @@
|
||||
self.ws = new WebSocket(scheme + '://' + window.location.host + '/ws/voice/' + roomId + '/');
|
||||
self.ws.onmessage = function (e) { self._onMessage(JSON.parse(e.data)); };
|
||||
self.ws.onclose = function () { self._teardown(); };
|
||||
self._notify(); // mic live (in-call), still alone
|
||||
return self;
|
||||
});
|
||||
};
|
||||
@@ -169,6 +196,7 @@
|
||||
t.enabled = !this.muted;
|
||||
}, this);
|
||||
}
|
||||
this._notify();
|
||||
return this.muted;
|
||||
};
|
||||
|
||||
@@ -178,6 +206,7 @@
|
||||
this.localStream.getTracks().forEach(function (t) { t.stop(); });
|
||||
this.localStream = null;
|
||||
}
|
||||
this._notify(); // call ended — back to not-in-call
|
||||
};
|
||||
|
||||
VoiceRoom.prototype.leave = function () {
|
||||
|
||||
Reference in New Issue
Block a user