fix(nests): tighten action bar state coverage

Several gaps in the action bar were causing dead-end UI states or
making non-actionable controls visible:

* Hand-raise was rendered while disconnected / connecting / failed.
  Raising can't be delivered to the room until we're Connected, so
  hide it until the connection state agrees. Threaded `isConnected`
  into `EndCluster` from the root composable.
* Audience listen-mute toggle (Volume Up/Off) removed. System volume
  keys cover local volume; the on-screen control was redundant and
  shared the same speaker glyph as the broadcasting mic-mute toggle,
  which made the two affordances easy to confuse.
* On-stage user without `canBroadcast` had no way to step down without
  leaving the whole room. Show a "Leave the Stage" outlined button
  in that branch so the speaker slot can be released.
* Broadcast `Connecting` and `Failed` substates also had no
  step-down affordance — forcing the user to either retry the mic or
  leave the room. Added "Leave the Stage" to both. `stopBroadcast()`
  cancels the in-flight `speakerConnectJob` (NestViewModel
  `teardownBroadcast`), so cancelling mid-handshake is safe.
* `BroadcastUiState.Broadcasting.muteError` was tracked in state but
  never surfaced. Route it through `ActionBarStatusStrip`.
* Permission denial pill said "Open settings"; renamed to
  "No permissions" so the label communicates the *state* (the tap
  still deep-links to the system settings page).
This commit is contained in:
Claude
2026-04-30 19:02:25 +00:00
parent b86219b1e1
commit 640e7904e4
2 changed files with 44 additions and 29 deletions
@@ -126,6 +126,7 @@ internal fun NestActionBar(
}
EndCluster(
isOnStage = isOnStage,
isConnected = ui.connection is ConnectionUiState.Connected,
handRaised = handRaised,
onHandRaisedChange = onHandRaisedChange,
onShowReactionPicker = onShowReactionPicker,
@@ -150,6 +151,12 @@ private fun ActionBarStatusStrip(ui: NestUiState) {
stringRes(R.string.nest_broadcast_failed, broadcast.reason)
}
is BroadcastUiState.Broadcasting -> {
broadcast.muteError?.let {
stringRes(R.string.nest_mute_failed, it)
}
}
else -> {
null
}
@@ -201,33 +208,27 @@ private fun StartCluster(
}
is ConnectionUiState.Connected -> {
if (canBroadcast && isOnStage) {
OnStageControls(
viewModel = viewModel,
ui = ui,
speakerPubkeyHex = speakerPubkeyHex,
)
} else {
// Audience: a single mute toggle for the inbound
// listener stream. Mirrors the old ConnectionRow.
FilledTonalIconToggleButton(
checked = ui.isMuted,
onCheckedChange = { viewModel.setMuted(it) },
modifier = Modifier.size(width = 40.dp, height = ButtonDefaults.MinHeight),
) {
Icon(
symbol =
if (ui.isMuted) {
MaterialSymbols.AutoMirrored.VolumeOff
} else {
MaterialSymbols.AutoMirrored.VolumeUp
},
contentDescription =
stringRes(
if (ui.isMuted) R.string.nest_unmute else R.string.nest_mute,
),
when {
canBroadcast && isOnStage -> {
OnStageControls(
viewModel = viewModel,
ui = ui,
speakerPubkeyHex = speakerPubkeyHex,
)
}
isOnStage -> {
// On stage but cannot broadcast (no signer / no permission).
// Only sensible affordance is to step back down to audience.
OutlinedButton(onClick = { viewModel.setOnStage(false) }) {
Text(stringRes(R.string.nest_leave_stage))
}
}
else -> {
// Audience: nothing to do here. System volume keys cover
// local volume; the mute toggle was redundant.
}
}
}
@@ -326,6 +327,14 @@ private fun OnStageControls(
enabled = false,
label = { Text(stringRes(R.string.nest_broadcast_connecting)) },
)
// Always offer a way down. stopBroadcast() cancels the
// in-flight speakerConnectJob (NestViewModel.teardownBroadcast).
OutlinedButton(onClick = {
viewModel.stopBroadcast()
viewModel.setOnStage(false)
}) {
Text(stringRes(R.string.nest_leave_stage))
}
}
is BroadcastUiState.Broadcasting -> {
@@ -398,6 +407,9 @@ private fun OnStageControls(
modifier = Modifier.size(28.dp),
)
}
OutlinedButton(onClick = { viewModel.setOnStage(false) }) {
Text(stringRes(R.string.nest_leave_stage))
}
}
}
}
@@ -405,6 +417,7 @@ private fun OnStageControls(
@Composable
private fun EndCluster(
isOnStage: Boolean,
isConnected: Boolean,
handRaised: Boolean,
onHandRaisedChange: (Boolean) -> Unit,
onShowReactionPicker: () -> Unit,
@@ -414,9 +427,10 @@ private fun EndCluster(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
// Hand-raise only makes sense for audience: if you're already
// on stage, you don't need to ask. Hidden when on-stage.
if (!isOnStage) {
// Hand-raise only makes sense for audience that's actually
// connected — if you're already on stage you don't need to ask,
// and raising while disconnected can't be delivered to the room.
if (!isOnStage && isConnected) {
FilledTonalIconToggleButton(
checked = handRaised,
onCheckedChange = onHandRaisedChange,
+2 -1
View File
@@ -523,8 +523,9 @@
<string name="nest_broadcast_connecting">Going live…</string>
<string name="nest_broadcasting">Live</string>
<string name="nest_broadcast_failed">Broadcast failed: %1$s</string>
<string name="nest_mute_failed">Mute failed: %1$s</string>
<string name="nest_record_permission_required">Microphone access is required to talk in this room.</string>
<string name="nest_open_settings">Open settings</string>
<string name="nest_open_settings">No permissions</string>
<string name="nest_notification_channel">Nests</string>
<string name="nest_notification_channel_description">Keeps audio playing while a room is open.</string>
<string name="nest_notification_listening">Nest connected</string>