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:
+5
-2
@@ -173,13 +173,16 @@ private fun AdminCommandsCollector(
|
||||
tags = mapOf("a" to listOf(roomATag), "p" to listOf(localPubkey)),
|
||||
)
|
||||
LocalCache.observeNewEvents<AdminCommandEvent>(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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -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,
|
||||
|
||||
+1
@@ -290,6 +290,7 @@ internal fun NestFullScreen(
|
||||
target = target,
|
||||
event = event,
|
||||
accountViewModel = accountViewModel,
|
||||
nestViewModel = viewModel,
|
||||
onDismiss = { hostMenuTarget = null },
|
||||
catalog = speakerCatalogs[target],
|
||||
)
|
||||
|
||||
+17
-1
@@ -120,7 +120,6 @@ internal fun StageGrid(
|
||||
connectingSpeakers: ImmutableSet<String> = 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),
|
||||
|
||||
@@ -492,6 +492,7 @@
|
||||
<string name="meeting_room_in_space">In %1$s</string>
|
||||
<string name="meeting_room_in_space_unknown">a Nest</string>
|
||||
<string name="nest_stage">Stage</string>
|
||||
<string name="nest_stage_empty">Waiting for speakers…</string>
|
||||
<string name="nest_audience">Audience</string>
|
||||
<string name="nest_raise_hand">Raise hand</string>
|
||||
<string name="nest_lower_hand">Lower hand</string>
|
||||
@@ -569,8 +570,12 @@
|
||||
<string name="nest_hand_raise_queue_title">Raised hands</string>
|
||||
<string name="nest_hand_raise_approve">Approve</string>
|
||||
<string name="nest_promote_speaker">Promote to speaker</string>
|
||||
<string name="nest_promote_moderator">Promote to moderator</string>
|
||||
<string name="nest_demote_listener">Demote to listener</string>
|
||||
<string name="nest_force_mute">Force-mute speaker</string>
|
||||
<string name="nest_kick_action">Kick</string>
|
||||
<string name="nest_hush_local">Hush this speaker</string>
|
||||
<string name="nest_hush_local_restore">Restore this speaker</string>
|
||||
<string name="nest_participant_view_profile">View profile</string>
|
||||
<string name="nest_participant_zap">Send zap</string>
|
||||
<string name="nest_participant_zap_split_unsupported">Split zaps are not supported from inside a nest. Open the profile screen to send.</string>
|
||||
|
||||
+54
-2
@@ -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?
|
||||
|
||||
+14
-7
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+23
@@ -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<AdminCommandEvent>(KIND, Action.MUTE.code, createdAt) {
|
||||
aTag(room)
|
||||
add(PTag(target).toTagArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user