From d41a24f9458caf30c4161db20383d959e96d84bc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 12:41:23 +0000 Subject: [PATCH] feat(nests): empty-stage hint, local hush, moderator/force-mute moderation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- .../room/lifecycle/NestRoomEventCollectors.kt | 7 ++- .../ParticipantHostActionsSheet.kt | 35 ++++++++++++ .../nests/room/screen/NestFullScreen.kt | 1 + .../nests/room/stage/ParticipantsGrid.kt | 18 +++++- amethyst/src/main/res/values/strings.xml | 5 ++ .../commons/viewmodels/NestViewModel.kt | 56 ++++++++++++++++++- .../nestsclient/audio/AudioTrackPlayer.kt | 21 ++++--- .../vitorpamplona/nestsclient/audio/Audio.kt | 15 +++++ .../nests/admin/AdminCommandEvent.kt | 23 ++++++++ 9 files changed, 169 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt index 5d983daf2..fa0a11848 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt @@ -173,13 +173,16 @@ private fun AdminCommandsCollector( tags = mapOf("a" to listOf(roomATag), "p" to listOf(localPubkey)), ) LocalCache.observeNewEvents(filter).collect { cmd -> - if (cmd.action() != AdminCommandEvent.Action.KICK) return@collect if (cmd.targetPubkey() != localPubkey) return@collect val signerIsAuthorised = cmd.pubKey == event.pubKey || event.participants().any { it.pubKey == cmd.pubKey && (it.isHost() || it.isModerator()) } if (!signerIsAuthorised) return@collect - viewModel.onKick() + when (cmd.action()) { + AdminCommandEvent.Action.KICK -> viewModel.onKick() + AdminCommandEvent.Action.MUTE -> viewModel.onForceMuted() + null -> Unit // unknown verb, ignore + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt index e97438c83..2915b686c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -82,6 +83,7 @@ internal fun ParticipantHostActionsSheet( event: MeetingSpaceEvent, accountViewModel: AccountViewModel, onDismiss: () -> Unit, + nestViewModel: com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel? = null, isLocalUserHost: Boolean = accountViewModel.account.signer.pubKey == event.pubKey, catalog: com.vitorpamplona.amethyst.commons.viewmodels.RoomSpeakerCatalog? = null, ) { @@ -252,6 +254,28 @@ internal fun ParticipantHostActionsSheet( onDismiss() } + // Local hush — silences only this speaker in our own + // playback. Available to anyone (not host-only) since it + // affects nothing on the wire. Skip self because we don't + // subscribe to our own broadcast loopback. + if (nestViewModel != null && target != accountViewModel.account.signer.pubKey) { + val nestUi by nestViewModel.uiState.collectAsState() + val isHushedLocally = target in nestUi.locallyHushed + ActionRow( + text = + stringRes( + if (isHushedLocally) { + R.string.nest_hush_local_restore + } else { + R.string.nest_hush_local + }, + ), + ) { + nestViewModel.setLocalHushed(target, !isHushedLocally) + onDismiss() + } + } + // Host-only rows. if (isLocalUserHost && target != event.pubKey) { Spacer(Modifier.height(4.dp)) @@ -259,10 +283,21 @@ internal fun ParticipantHostActionsSheet( broadcast(RoomParticipantActions.setRole(event, target, ROLE.SPEAKER)) onDismiss() } + ActionRow(stringRes(R.string.nest_promote_moderator)) { + broadcast(RoomParticipantActions.setRole(event, target, ROLE.MODERATOR)) + onDismiss() + } ActionRow(stringRes(R.string.nest_demote_listener)) { broadcast(RoomParticipantActions.demoteToListener(event, target)) onDismiss() } + ActionRow( + text = stringRes(R.string.nest_force_mute), + color = MaterialTheme.colorScheme.error, + ) { + broadcast(AdminCommandEvent.forceMute(roomATag, target)) + onDismiss() + } ActionRow( text = stringRes(R.string.nest_kick_action), color = MaterialTheme.colorScheme.error, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt index 7092049d5..4a7b15739 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt @@ -290,6 +290,7 @@ internal fun NestFullScreen( target = target, event = event, accountViewModel = accountViewModel, + nestViewModel = viewModel, onDismiss = { hostMenuTarget = null }, catalog = speakerCatalogs[target], ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt index 180177241..11cdb7c7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt @@ -120,7 +120,6 @@ internal fun StageGrid( connectingSpeakers: ImmutableSet = persistentSetOf(), onLongPressParticipant: ((String) -> Unit)? = null, ) { - if (members.isEmpty()) return // Float currently-speaking members to the top so the listener can // see who they're hearing without scrolling. sortedBy is stable in // Kotlin — speakers retain relative order among themselves, as do @@ -137,6 +136,23 @@ internal fun StageGrid( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 6.dp), ) + if (members.isEmpty()) { + // Keep the strip visible so the room doesn't look broken + // when nobody is on stage yet — the title plus a quiet + // hint signals "waiting state" without taking the chat / + // audience tab's space below. + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringRes(R.string.nest_stage_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return@Column + } LazyVerticalGrid( columns = GridCells.Adaptive(STAGE_CELL_MIN), modifier = Modifier.fillMaxWidth().heightIn(max = STAGE_MAX_HEIGHT), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bfad4b328..0fc165d1d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -492,6 +492,7 @@ In %1$s a Nest Stage + Waiting for speakers… Audience Raise hand Lower hand @@ -569,8 +570,12 @@ Raised hands Approve Promote to speaker + Promote to moderator Demote to listener + Force-mute speaker Kick + Hush this speaker + Restore this speaker View profile Send zap Split zaps are not supported from inside a nest. Open the profile screen to send. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt index 2cecca768..32e660297 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt @@ -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 = persistentSetOf(), ) { /** * Derived: are we currently pushing audio packets to the relay? diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt index d465cbc7c..c0be0e0dd 100644 --- a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt @@ -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) } } } diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt index c83c4e81e..cbdcfed80 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt @@ -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() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nests/admin/AdminCommandEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nests/admin/AdminCommandEvent.kt index 0c04bd355..89cc44192 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nests/admin/AdminCommandEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nests/admin/AdminCommandEvent.kt @@ -71,6 +71,16 @@ class AdminCommandEvent( val code: String, ) { KICK("kick"), + + /** + * Force the target speaker's mic to muted. Honor-based: the + * target's client receives the command and flips its own + * presence-side mic-mute. A non-cooperating client could + * ignore the event and keep broadcasting — same trust model + * as KICK, which a misbehaving client could also ignore by + * not disconnecting. + */ + MUTE("mute"), ; companion object { @@ -95,5 +105,18 @@ class AdminCommandEvent( aTag(room) add(PTag(target).toTagArray()) } + + /** + * Build a force-mute command. Same envelope as [kick] — + * tagged for [room] + [target], action verb in content. + */ + fun forceMute( + room: ATag, + target: HexKey, + createdAt: Long = TimeUtils.now(), + ) = eventTemplate(KIND, Action.MUTE.code, createdAt) { + aTag(room) + add(PTag(target).toTagArray()) + } } }