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
@@ -231,6 +231,21 @@ class NestViewModel(
disconnect()
}
/**
* Honor an inbound kind-4312 force-mute targeted at the local
* user. No-ops when we aren't broadcasting; otherwise routes
* through the existing [setMicMuted] path so the wire mute and
* UI state both flip in sync. Caller (the AdminCommandsCollector)
* is responsible for verifying the signer is host/moderator on
* the active kind-30312 — the relay does not enforce that.
*/
fun onForceMuted() {
if (closed) return
if (broadcastHandle == null) return
if ((_uiState.value.broadcast as? BroadcastUiState.Broadcasting)?.isMuted == true) return
setMicMuted(true)
}
private var listener: NestsListener? = null
private var connectJob: Job? = null
private var stateObserverJob: Job? = null
@@ -307,6 +322,31 @@ class NestViewModel(
activeSubscriptions.values.forEach { it.player?.setMutedSafe(muted) }
}
/**
* Locally silence (hush) one speaker without affecting the rest of
* the room. Independent of [setMuted] (room-wide). The hush is
* applied via [AudioPlayer.setVolume] so the network + decode
* pipeline keeps running — re-enabling is sample-accurate.
*
* The set survives subscription churn: re-subscribing to a hushed
* speaker re-applies the gain on the new player at attach time.
*/
fun setLocalHushed(
pubkey: String,
hushed: Boolean,
) {
if (closed) return
val updated =
_uiState.value.locallyHushed.let {
if (hushed) it + pubkey else it - pubkey
}
if (updated == _uiState.value.locallyHushed) return
_uiState.update { it.copy(locallyHushed = updated.toPersistentSet()) }
activeSubscriptions[pubkey]?.player?.runCatching {
setVolume(if (hushed) 0f else 1f)
}
}
/**
* Toggle whether we hold a speaker slot. Tier-2's "leave the stage"
* tap flips this to `false`; the next kind-10312 heartbeat picks up
@@ -787,10 +827,14 @@ class NestViewModel(
}
try {
val isMuted = _uiState.value.isMuted
val isHushed = pubkey in _uiState.value.locallyHushed
val roomPlayer = NestPlayer(decoder, player, viewModelScope)
// Apply current mute state before play() opens the device so the
// first frame respects it.
// Apply current mute + per-speaker hush state before play()
// opens the device so the first frame respects them.
player.setMutedSafe(isMuted)
if (isHushed) {
runCatching { player.setVolume(0f) }
}
// Tap the object flow to drive the speaking-now indicator before
// the decoder consumes it.
val instrumented = handle.objects.onEach { onSpeakerActivity(pubkey) }
@@ -1114,6 +1158,14 @@ data class NestUiState(
* `["onstage", "0|1"]` tag on emitted kind-10312 presence events.
*/
val onStageNow: Boolean = true,
/**
* Pubkeys the local user has chosen to silence in their own
* playback ("hush"). Independent of [isMuted]. Persists across
* subscription churn so re-subscribing to a hushed speaker stays
* silent. Drives a "Hush this speaker locally" toggle in the
* participant context sheet.
*/
val locallyHushed: ImmutableSet<String> = persistentSetOf(),
) {
/**
* Derived: are we currently pushing audio packets to the relay?