fix: move on-stage controls out of action bar onto stage card

NestActionBar held the speaker controls (Talk / MicMute / Leave the
Stage) alongside the audience controls (hand-raise / react / leave
room), making the bar uncomfortably wide on phones once a user was
promoted.

Splits responsibilities:
  - NestActionBar now only handles connection state in the start
    cluster (Connect / Connecting chip / Reconnecting chip / empty)
    plus the existing end cluster.
  - StageControlsBar is a new strip rendered under the Stage card,
    gated only on isOnStage, holding the full broadcast state machine.

isOnStage is the single visibility signal — a stable derived value
from the kind-30312 role + presence onstage flag — so connection
blips, broadcast state churn, mute toggles, and permission flows
never make the new bar appear or disappear. It only flips on a real
promote/demote or the user tapping Leave the Stage.
This commit is contained in:
Claude
2026-05-01 20:57:07 +00:00
parent e7cb6dae5f
commit 9c26c51a1b
2 changed files with 68 additions and 43 deletions
@@ -74,18 +74,17 @@ import com.vitorpamplona.amethyst.ui.stringRes
* Layout: `[ start cluster ] · · · [ end cluster ]` with an optional
* red status strip above for connection / broadcast / mute failures.
*
* Start cluster — driven by connection × broadcast × on-stage state:
* Start cluster — driven by connection state only:
*
* | State | Start cluster contents |
* |---------------------------------------------|-----------------------------------------|
* |------------------------------------|------------------------|
* | Idle / Closed / Failed connection | `[Connect]` |
* | Connecting / Reconnecting | status chip |
* | Connected, audience | empty (system volume keys are enough) |
* | Connected, on stage, !canBroadcast | `[Leave the Stage]` |
* | Connected, on stage, mic idle | `[Talk] [Leave the Stage]` (+ pill) |
* | Connected, on stage, going live | status chip + `[Leave the Stage]` |
* | Connected, on stage, broadcasting | `[MicMute] [Leave the Stage]` |
* | Connected, on stage, broadcast failed | `[Retry] [Leave the Stage]` |
* | Connecting | status chip |
* | Reconnecting | status chip |
* | Connected | empty |
*
* On-stage controls (Talk / MicMute / Leave the Stage) live in
* [StageControlsBar], attached to the bottom of the Stage card.
*
* End cluster: hand-raise (audience + connected only), react, leave room.
*/
@@ -94,8 +93,6 @@ internal fun NestActionBar(
viewModel: NestViewModel,
ui: NestUiState,
isOnStage: Boolean,
canBroadcast: Boolean,
speakerPubkeyHex: String,
handRaised: Boolean,
onHandRaisedChange: (Boolean) -> Unit,
onShowReactionPicker: () -> Unit,
@@ -116,13 +113,7 @@ internal fun NestActionBar(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(modifier = Modifier.weight(1f, fill = true)) {
StartCluster(
viewModel = viewModel,
ui = ui,
isOnStage = isOnStage,
canBroadcast = canBroadcast,
speakerPubkeyHex = speakerPubkeyHex,
)
StartCluster(viewModel = viewModel, ui = ui)
}
EndCluster(
isOnStage = isOnStage,
@@ -137,6 +128,49 @@ internal fun NestActionBar(
}
}
/**
* Controls strip that attaches under the Stage card. Contains
* everything a speaker needs while on stage: Talk / MicMute / Retry +
* Leave the Stage. Renders nothing when the local user isn't on stage.
*
* Visibility is gated on [isOnStage] alone — a stable signal driven
* by the kind-30312 role + presence `onstage` flag — so connection
* blips, broadcast state churn, mute toggles, and permission flows
* never make the bar appear or disappear. It only shows up on a real
* promote, and only goes away on a real demote (or the user tapping
* Leave the Stage).
*/
@Composable
internal fun StageControlsBar(
viewModel: NestViewModel,
ui: NestUiState,
isOnStage: Boolean,
canBroadcast: Boolean,
speakerPubkeyHex: String,
modifier: Modifier = Modifier,
) {
if (!isOnStage) return
Row(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (canBroadcast) {
OnStageControls(
viewModel = viewModel,
broadcast = ui.broadcast,
speakerPubkeyHex = speakerPubkeyHex,
)
} else {
// No signing/permission — only thing we can do is step down.
LeaveStageButton(onClick = { viewModel.setOnStage(false) })
}
}
}
/** Single-line red error strip. Surfaces the most relevant failure. */
@Composable
private fun ActionBarStatusStrip(ui: NestUiState) {
@@ -166,9 +200,6 @@ private fun NestUiState.statusStripText(): String? {
private fun StartCluster(
viewModel: NestViewModel,
ui: NestUiState,
isOnStage: Boolean,
canBroadcast: Boolean,
speakerPubkeyHex: String,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -190,29 +221,13 @@ private fun StartCluster(
StatusChip(label = stringRes(R.string.nest_reconnecting))
}
// On-stage controls live in [StageControlsBar]; audience
// has nothing to do here (system volume keys are enough).
is ConnectionUiState.Connected -> {
when {
canBroadcast && isOnStage -> {
OnStageControls(
viewModel = viewModel,
broadcast = ui.broadcast,
speakerPubkeyHex = speakerPubkeyHex,
)
}
// On stage but no signing/permission — only thing we can do is step down.
isOnStage -> {
LeaveStageButton(onClick = { viewModel.setOnStage(false) })
}
// Audience: nothing here. Phone volume keys cover local volume.
else -> {
Unit
}
}
}
}
}
}
@Composable
@@ -247,12 +247,22 @@ internal fun NestFullScreen(
listenerCount = presences.size,
modifier = Modifier.padding(horizontal = 16.dp),
)
NestActionBar(
// Speaker controls live attached to the Stage card so they
// only render when the user is actually on stage. This keeps
// the action bar narrow for audience and makes the controls
// appearing/disappearing a deliberate signal that the mic
// is now (or no longer) available.
StageControlsBar(
viewModel = viewModel,
ui = ui,
isOnStage = isOnStageMe,
canBroadcast = viewModel.canBroadcast,
speakerPubkeyHex = myPubkey,
)
NestActionBar(
viewModel = viewModel,
ui = ui,
isOnStage = isOnStageMe,
handRaised = handRaised,
onHandRaisedChange = onHandRaisedChange,
onShowReactionPicker = { showReactionPicker = true },