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
@@ -42,6 +42,7 @@ class AudioTrackPlayer(
) : AudioPlayer {
private var track: AudioTrack? = null
private var muted: Boolean = false
private var volume: Float = 1f
override fun start() {
if (track != null) return
@@ -105,7 +106,7 @@ class AudioTrackPlayer(
t,
)
}
applyMuteVolume(newTrack, muted)
applyMuteVolume(newTrack)
track = newTrack
}
@@ -126,7 +127,12 @@ class AudioTrackPlayer(
override fun setMuted(muted: Boolean) {
this.muted = muted
track?.let { applyMuteVolume(it, muted) }
track?.let { applyMuteVolume(it) }
}
override fun setVolume(volume: Float) {
this.volume = volume.coerceIn(0f, 1f)
track?.let { applyMuteVolume(it) }
}
override fun stop() {
@@ -141,13 +147,14 @@ class AudioTrackPlayer(
@Suppress("unused")
val voiceCallUsage: Int get() = AudioManager.STREAM_VOICE_CALL // kept for documentation
private fun applyMuteVolume(
track: AudioTrack,
muted: Boolean,
) {
private fun applyMuteVolume(track: AudioTrack) {
// setVolume is preferred over pause(): it keeps the streaming pipeline
// running so unmute is sample-accurate and there's no AudioTrack-restart
// glitch. AudioTrack default gain is 1.0 (RFC: AudioTrack#setVolume).
runCatching { track.setVolume(if (muted) 0f else 1f) }
// Mute and per-stream volume compose multiplicatively — a muted stream
// is silent regardless of volume, and a stream at volume 0 is silent
// regardless of mute.
val gain = if (muted) 0f else volume
runCatching { track.setVolume(gain) }
}
}