feat(nests): empty-stage hint, local hush, moderator/force-mute moderation

Three improvements stacked into one commit since they share files:

#3 Empty-stage hint: StageGrid no longer disappears when nobody is on
   stage. The "Stage" label stays and a quiet "Waiting for speakers…"
   line keeps the strip visible so the room doesn't look broken before
   the first speaker arrives.

#5 Local per-speaker hush: AudioPlayer gains a setVolume(Float)
   default-no-op method; AudioTrackPlayer composes mute and volume
   multiplicatively into AudioTrack.setVolume so a hushed stream stays
   silent regardless of mute state. NestViewModel exposes
   locallyHushed: ImmutableSet<String> in NestUiState plus
   setLocalHushed(pubkey, hushed) — applied at attach time so a
   re-subscribe of an already-hushed speaker stays silent. New "Hush
   this speaker" / "Restore this speaker" row in
   ParticipantHostActionsSheet, available to anyone (it affects only
   our own playback, nothing on the wire).

#4 Host moderation gaps: wires Promote-to-Moderator using the existing
   RoomParticipantActions.setRole(ROLE.MODERATOR) builder — UI gap
   only, no protocol change. Adds a new AdminCommandEvent.Action.MUTE
   variant + AdminCommandEvent.forceMute(room, target) builder; the
   sheet emits it on "Force-mute speaker" and the
   AdminCommandsCollector dispatches incoming MUTE actions to a new
   NestViewModel.onForceMuted() that routes through the existing
   setMicMuted(true) path. Honor-based, same trust model as KICK —
   relays don't enforce signer authority, the client checks the
   signer is host or moderator on the active kind-30312.
This commit is contained in:
Claude
2026-04-28 12:41:23 +00:00
parent bed1b328ce
commit d41a24f945
9 changed files with 169 additions and 12 deletions
@@ -121,6 +121,21 @@ interface AudioPlayer {
*/
fun setMuted(muted: Boolean)
/**
* Per-stream output gain, range `[0.0, 1.0]`. 1.0 is the device
* default; 0.0 silences this stream while leaving any other
* concurrent players untouched. Used to "hush" one speaker
* locally without affecting the rest of the room.
*
* Independent of [setMuted]: muted streams are silent regardless of
* gain, and an unmuted stream at gain 0 is still silent. The two
* controls compose multiplicatively. Default gain is 1.0.
*
* Default implementation is a no-op so existing fakes / capture-only
* actuals don't have to grow a method they'll never use.
*/
fun setVolume(volume: Float) {}
/** Stop playback and release resources. After this, the player is unusable. */
fun stop()
}