feat(calls): propagate voice mute state over WS so peers see it (LC-407) #420

Merged
longjacksonle merged 1 commit from feat/lc-402-remote-mute into main 2026-06-21 00:48:13 +02:00

Implements LC-407 (subtask of the LC-402 call redesign): propagate mute state over the chat WebSocket so a peer's tile shows when they mute, not just your own.

Why

After the redesign each tile has a mute indicator + active-speaker ring, but only the LOCAL mute was wired - a remote mute was invisible. Active-speaker for remotes already works (the analyser taps each peer's received audio and is suppressed while data-muted is set), so only the discrete mute flag needs to travel over the wire.

How

  • Server: new ClientFrame::voice_mute { room_id, muted }. The handler checks is_in_voice_room(conn_id, room_id) and broadcasts ChatEvent::VoiceMuteChanged { room_id, user_id, muted } to the room (same fan-out as VoiceJoined). Rendered to #lc-voice-bus via voice_event.html as kind="mute" with payload "1"/"0". The recipient-independent render_event returns None for it (handled per recipient in the WS send task, like the other voice events).
  • Client (voice.js): toggleMute sets selfMuted, applies data-muted locally, and sends voice_mute. Inbound kind === 'mute' applies data-muted on the peer's tile. A muted participant re-announces when someone new joins (broadcast reaches the newcomer too), so a late joiner sees the correct mic state. selfMuted resets on join/leave.

Scope

ws/events.rs (1 variant), routes/ws.rs (frame + inbound handler + render arm + dispatch), views/ws_fragments.rs (None arm), assets/voice.js. No template, CSS (the badge + ring already exist), or schema change.

Note

Active-speaker is intentionally NOT broadcast - it is derived locally from received audio every 250ms and would be needless WS spam to propagate. Only the infrequent mute toggle goes over the wire.

Tests

just check (standalone + saas compile, clippy, fmt), just test, just test-saas all pass. voice.js syntax-checked.

Implements LC-407 (subtask of the LC-402 call redesign): propagate mute state over the chat WebSocket so a peer's tile shows when they mute, not just your own. ## Why After the redesign each tile has a mute indicator + active-speaker ring, but only the LOCAL mute was wired - a remote mute was invisible. Active-speaker for remotes already works (the analyser taps each peer's received audio and is suppressed while `data-muted` is set), so only the discrete mute flag needs to travel over the wire. ## How - **Server:** new `ClientFrame::voice_mute { room_id, muted }`. The handler checks `is_in_voice_room(conn_id, room_id)` and broadcasts `ChatEvent::VoiceMuteChanged { room_id, user_id, muted }` to the room (same fan-out as `VoiceJoined`). Rendered to `#lc-voice-bus` via `voice_event.html` as `kind="mute"` with `payload` `"1"`/`"0"`. The recipient-independent `render_event` returns `None` for it (handled per recipient in the WS send task, like the other voice events). - **Client (voice.js):** `toggleMute` sets `selfMuted`, applies `data-muted` locally, and sends `voice_mute`. Inbound `kind === 'mute'` applies `data-muted` on the peer's tile. A muted participant re-announces when someone new joins (broadcast reaches the newcomer too), so a late joiner sees the correct mic state. `selfMuted` resets on join/leave. ## Scope `ws/events.rs` (1 variant), `routes/ws.rs` (frame + inbound handler + render arm + dispatch), `views/ws_fragments.rs` (None arm), `assets/voice.js`. No template, CSS (the badge + ring already exist), or schema change. ## Note Active-speaker is intentionally NOT broadcast - it is derived locally from received audio every 250ms and would be needless WS spam to propagate. Only the infrequent mute toggle goes over the wire. ## Tests `just check` (standalone + saas compile, clippy, fmt), `just test`, `just test-saas` all pass. `voice.js` syntax-checked.
feat(calls): propagate voice mute state over WS so peers see it (LC-407)
All checks were successful
check-secrets / Nosey parker (push) Successful in 4s
check-secrets / Kingfisher (push) Successful in 4s
check-secrets / TruffleHog (push) Successful in 5s
check-secrets / Nosey parker (pull_request) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 5s
check-secrets / TruffleHog (pull_request) Successful in 5s
Create release / Create release from merged PR (pull_request) Has been skipped
Check / clippy + fmt + tests (pull_request) Successful in 4m43s
142bb80ea2
Until now only the LOCAL mute was reflected on a tile; a participant could not tell when another peer muted their mic. Add a lightweight room broadcast for the discrete mute flag (active-speaker is already detected locally from each peer's received audio, so it does not need wiring).

- New ClientFrame `voice_mute { room_id, muted }`; the handler verifies the connection is in that voice room and broadcasts ChatEvent::VoiceMuteChanged to the room (like VoiceJoined), rendered to the voice bus as kind="mute" with payload "1"/"0".
- voice.js broadcasts on every local mute toggle and applies an incoming mute to the peer's tile via data-muted (CSS draws the existing mute badge; pollSpeaking already suppresses the active-speaker ring while data-muted is true).
- A muted participant re-announces on a late join, so a newcomer learns the current mic state instead of seeing an out-of-date tile. selfMuted resets on join/leave.

Server: one new event variant + frame + render arm; the recipient-independent render_event returns None for it (handled per recipient in the WS send task). Frontend: voice.js only. No schema change. just check, just test, just test-saas all pass; voice.js syntax-checked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch feat/lc-402-remote-mute 2026-06-21 00:48:13 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!420
No description provided.