Merge pull request #2667 from vitorpamplona/claude/fix-mute-button-height-6Vfip

Refactor NestActionBar UI layout and state handling
This commit is contained in:
Vitor Pamplona
2026-04-30 15:24:13 -04:00
committed by GitHub
2 changed files with 283 additions and 264 deletions
@@ -21,7 +21,11 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
@@ -34,7 +38,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledIconButton
@@ -66,21 +69,25 @@ import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Sticky bottom action bar for the room screen. Replaces the three
* separate rows (`ConnectionRow`, `TalkRow`, hand/react/leave) that
* used to scroll away with the rest of the metadata.
* Sticky bottom action bar for the room screen.
*
* Layout adapts to connection / broadcast / on-stage state:
* - Disconnected → `[Connect]` (start) · `[Leave]` (end)
* - Connecting / Reconnecting → state chip · `[Leave]`
* - Connected, audience → `[Listen mute toggle]` · `[Hand] [React] [Leave]`
* - Connected, on-stage idle → `[Talk] [Leave stage]` · `[React] [Leave]`
* - Connected, on-stage live → `[Mic mute] [Stop] [Leave stage]` · `[React] [Leave]`
* - Failed (connection or broadcast) → status strip above the bar
* plus a retry button in the start cluster.
* Layout: `[ start cluster ] · · · [ end cluster ]` with an optional
* red status strip above for connection / broadcast / mute failures.
*
* The Hand button hides when the user is already on stage — the
* action it represents ("I want to speak") doesn't apply once you can.
* Start cluster — driven by connection × broadcast × on-stage state:
*
* | 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] [Stop] [Leave the Stage]` |
* | Connected, on stage, broadcast failed | `[Retry] [Leave the Stage]` |
*
* End cluster: hand-raise (audience + connected only), react, leave room.
*/
@Composable
internal fun NestActionBar(
@@ -100,18 +107,11 @@ internal fun NestActionBar(
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 3.dp,
) {
Column(
modifier =
Modifier
.fillMaxWidth(),
) {
Column(modifier = Modifier.fillMaxWidth()) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
ActionBarStatusStrip(ui = ui)
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
@@ -126,6 +126,7 @@ internal fun NestActionBar(
}
EndCluster(
isOnStage = isOnStage,
isConnected = ui.connection is ConnectionUiState.Connected,
handRaised = handRaised,
onHandRaisedChange = onHandRaisedChange,
onShowReactionPicker = onShowReactionPicker,
@@ -136,27 +137,10 @@ internal fun NestActionBar(
}
}
/** Single-line red error strip. Surfaces the most relevant failure. */
@Composable
private fun ActionBarStatusStrip(ui: NestUiState) {
val text =
when (val connection = ui.connection) {
is ConnectionUiState.Failed -> {
stringRes(R.string.nest_audio_failed, connection.reason)
}
else -> {
when (val broadcast = ui.broadcast) {
is BroadcastUiState.Failed -> {
stringRes(R.string.nest_broadcast_failed, broadcast.reason)
}
else -> {
null
}
}
}
}
if (text == null) return
val text = ui.statusStripText() ?: return
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
@@ -165,6 +149,19 @@ private fun ActionBarStatusStrip(ui: NestUiState) {
)
}
@Composable
private fun NestUiState.statusStripText(): String? {
val connection = connection
if (connection is ConnectionUiState.Failed) {
return stringRes(R.string.nest_audio_failed, connection.reason)
}
return when (val b = broadcast) {
is BroadcastUiState.Failed -> stringRes(R.string.nest_broadcast_failed, b.reason)
is BroadcastUiState.Broadcasting -> b.muteError?.let { stringRes(R.string.nest_mute_failed, it) }
else -> null
}
}
@Composable
private fun StartCluster(
viewModel: NestViewModel,
@@ -178,61 +175,40 @@ private fun StartCluster(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
when (val connection = ui.connection) {
is ConnectionUiState.Idle, is ConnectionUiState.Closed -> {
Button(onClick = { viewModel.connect() }) {
Text(stringRes(R.string.nest_connect))
}
is ConnectionUiState.Idle,
is ConnectionUiState.Closed,
is ConnectionUiState.Failed,
-> {
ConnectButton(onClick = viewModel::connect)
}
is ConnectionUiState.Connecting -> {
AssistChip(
onClick = {},
enabled = false,
label = { Text(connectingLabel(connection)) },
)
StatusChip(label = connectingLabel(connection))
}
is ConnectionUiState.Reconnecting -> {
AssistChip(
onClick = {},
enabled = false,
label = { Text(stringRes(R.string.nest_reconnecting)) },
)
StatusChip(label = stringRes(R.string.nest_reconnecting))
}
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) },
) {
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,
broadcast = ui.broadcast,
speakerPubkeyHex = speakerPubkeyHex,
)
}
}
}
is ConnectionUiState.Failed -> {
Button(onClick = { viewModel.connect() }) {
Text(stringRes(R.string.nest_connect))
// 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
}
}
}
}
@@ -242,160 +218,78 @@ private fun StartCluster(
@Composable
private fun OnStageControls(
viewModel: NestViewModel,
ui: NestUiState,
broadcast: BroadcastUiState,
speakerPubkeyHex: String,
) {
val leaveStage = {
viewModel.stopBroadcast()
viewModel.setOnStage(false)
}
when (broadcast) {
BroadcastUiState.Idle -> {
OnStageIdleControls(
viewModel = viewModel,
speakerPubkeyHex = speakerPubkeyHex,
)
}
BroadcastUiState.Connecting -> {
StatusChip(label = stringRes(R.string.nest_broadcast_connecting))
// stopBroadcast() cancels the in-flight speakerConnectJob,
// so leaving mid-handshake is safe.
LeaveStageButton(onClick = leaveStage)
}
is BroadcastUiState.Broadcasting -> {
MicMuteToggle(isMuted = broadcast.isMuted, onToggle = viewModel::setMicMuted)
StopBroadcastButton(onClick = viewModel::stopBroadcast)
LeaveStageButton(onClick = leaveStage)
}
is BroadcastUiState.Failed -> {
// Reason is shown in the status strip; this button retries.
TalkButton(
onClick = { viewModel.startBroadcast(speakerPubkeyHex) },
contentDescription = stringRes(R.string.nest_talk),
)
LeaveStageButton(onClick = { viewModel.setOnStage(false) })
}
}
}
@Composable
private fun OnStageIdleControls(
viewModel: NestViewModel,
speakerPubkeyHex: String,
) {
val context = LocalContext.current
var permissionDenied by rememberSaveable { mutableStateOf(false) }
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
permissionDenied = false
permissionDenied = !granted
if (granted) viewModel.startBroadcast(speakerPubkeyHex)
}
// The launcher callback never fires for Settings-deep-link grants, so
// re-check on every recomposition to auto-clear the warning.
val showDenialWarning =
permissionDenied && !context.hasMicPermission()
TalkButton(
onClick = {
if (context.hasMicPermission()) {
viewModel.startBroadcast(speakerPubkeyHex)
} else {
permissionDenied = true
}
}
// If the user grants RECORD_AUDIO via the system Settings deep-link
// and returns to the activity, the launcher callback never fires —
// recompute every time so the warning auto-clears.
val showDenialWarning =
permissionDenied &&
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) !=
PackageManager.PERMISSION_GRANTED
when (val broadcast = ui.broadcast) {
BroadcastUiState.Idle -> {
// Mic OFF visual: filled primary button signals "tap to go live".
// Sized larger than the surrounding 40.dp icon buttons so the
// mic state is unmistakable at a glance.
FilledIconButton(
onClick = {
val granted =
ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
viewModel.startBroadcast(speakerPubkeyHex)
} else {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
},
modifier = Modifier.size(56.dp),
colors =
IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
Icon(
symbol = MaterialSymbols.MicOff,
contentDescription = stringRes(R.string.nest_talk),
modifier = Modifier.size(28.dp),
)
}
OutlinedButton(onClick = { viewModel.setOnStage(false) }) {
Text(stringRes(R.string.nest_leave_stage))
}
if (showDenialWarning) {
// After "Don't ask again" the permission launcher
// silently returns false; surface a Settings deep-link.
OutlinedButton(onClick = {
runCatching {
context.startActivity(
android.content
.Intent(
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
android.net.Uri.fromParts("package", context.packageName, null),
).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
}) {
Text(stringRes(R.string.nest_open_settings))
}
}
}
BroadcastUiState.Connecting -> {
AssistChip(
onClick = {},
enabled = false,
label = { Text(stringRes(R.string.nest_broadcast_connecting)) },
)
}
is BroadcastUiState.Broadcasting -> {
AssistChip(
onClick = {},
enabled = false,
label = { Text(stringRes(R.string.nest_broadcasting)) },
colors =
AssistChipDefaults.assistChipColors(
disabledLabelColor = MaterialTheme.colorScheme.error,
),
)
FilledTonalIconToggleButton(
checked = broadcast.isMuted,
onCheckedChange = { viewModel.setMicMuted(it) },
) {
Icon(
symbol =
if (broadcast.isMuted) {
MaterialSymbols.AutoMirrored.VolumeOff
} else {
MaterialSymbols.AutoMirrored.VolumeUp
},
contentDescription =
stringRes(
if (broadcast.isMuted) R.string.nest_mic_unmute else R.string.nest_mic_mute,
),
)
}
// Mic ON visual: filled error-color button screams "MIC IS LIVE,
// tap to stop transmitting". Same large footprint as the Idle
// mic button so the state swap is impossible to miss.
FilledIconButton(
onClick = { viewModel.stopBroadcast() },
modifier = Modifier.size(56.dp),
colors =
IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) {
Icon(
symbol = MaterialSymbols.Mic,
contentDescription = stringRes(R.string.nest_stop_talking),
modifier = Modifier.size(28.dp),
)
}
OutlinedButton(onClick = {
viewModel.stopBroadcast()
viewModel.setOnStage(false)
}) {
Text(stringRes(R.string.nest_leave_stage))
}
}
is BroadcastUiState.Failed -> {
FilledIconButton(
onClick = { viewModel.startBroadcast(speakerPubkeyHex) },
modifier = Modifier.size(56.dp),
colors =
IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
Icon(
symbol = MaterialSymbols.MicOff,
contentDescription = stringRes(R.string.nest_talk),
modifier = Modifier.size(28.dp),
)
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
},
contentDescription = stringRes(R.string.nest_talk),
)
LeaveStageButton(onClick = { viewModel.setOnStage(false) })
if (showDenialWarning) {
// After "Don't ask again" the launcher silently returns false;
// expose a Settings deep-link.
OutlinedButton(onClick = { context.openAppSettings() }) {
Text(stringRes(R.string.nest_open_settings))
}
}
}
@@ -403,6 +297,7 @@ private fun OnStageControls(
@Composable
private fun EndCluster(
isOnStage: Boolean,
isConnected: Boolean,
handRaised: Boolean,
onHandRaisedChange: (Boolean) -> Unit,
onShowReactionPicker: () -> Unit,
@@ -412,24 +307,13 @@ 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) {
FilledTonalIconToggleButton(
checked = handRaised,
onCheckedChange = onHandRaisedChange,
) {
Icon(
symbol = MaterialSymbols.PanTool,
contentDescription =
stringRes(
if (handRaised) R.string.nest_lower_hand else R.string.nest_raise_hand,
),
)
}
// Hand-raise: only meaningful for connected audience. On stage
// it's moot; disconnected it can't reach the room.
if (!isOnStage && isConnected) {
HandRaiseToggle(handRaised = handRaised, onToggle = onHandRaisedChange)
}
// React is always visible — even disconnected users can
// (un)react via the reactions picker on the room note.
// React works in any state — even disconnected users can react
// via the room note.
FilledTonalIconButton(onClick = onShowReactionPicker) {
Icon(
symbol = MaterialSymbols.EmojiEmotions,
@@ -437,21 +321,139 @@ private fun EndCluster(
)
}
Spacer(Modifier.width(4.dp))
OutlinedButton(
onClick = onLeave,
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.Logout,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.width(6.dp))
Text(stringRes(R.string.nest_leave))
}
LeaveRoomButton(onClick = onLeave)
}
}
// ── Reusable affordances ────────────────────────────────────────────────
@Composable
private fun ConnectButton(onClick: () -> Unit) {
Button(onClick = onClick) {
Text(stringRes(R.string.nest_connect))
}
}
/** Disabled assist chip used as a status indicator (no click target). */
@Composable
private fun StatusChip(label: String) {
AssistChip(
onClick = {},
enabled = false,
label = { Text(label) },
)
}
@Composable
private fun LeaveStageButton(onClick: () -> Unit) {
OutlinedButton(onClick = onClick) {
Text(stringRes(R.string.nest_leave_stage))
}
}
/**
* Big primary 56dp mic button shown in the off-states (Idle, Failed)
* to invite the user to start broadcasting. Larger than surrounding
* 40dp icon buttons so the mic state is unmistakable.
*/
@Composable
private fun TalkButton(
onClick: () -> Unit,
contentDescription: String,
) {
FilledIconButton(
onClick = onClick,
modifier = Modifier.size(56.dp),
colors =
IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
Icon(
symbol = MaterialSymbols.MicOff,
contentDescription = contentDescription,
modifier = Modifier.size(28.dp),
)
}
}
/**
* Big error-color 56dp mic button shown while broadcasting. Same
* footprint as [TalkButton] so the on/off swap is impossible to miss.
*/
@Composable
private fun StopBroadcastButton(onClick: () -> Unit) {
FilledIconButton(
onClick = onClick,
modifier = Modifier.size(56.dp),
colors =
IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) {
Icon(
symbol = MaterialSymbols.Mic,
contentDescription = stringRes(R.string.nest_stop_talking),
modifier = Modifier.size(28.dp),
)
}
}
/**
* Cheap broadcast-side mute toggle. Keeps the MoQ session open and
* just stops sending audio frames; unmute is sample-accurate.
*/
@Composable
private fun MicMuteToggle(
isMuted: Boolean,
onToggle: (Boolean) -> Unit,
) {
FilledTonalIconToggleButton(
checked = isMuted,
onCheckedChange = onToggle,
modifier = Modifier.size(width = 40.dp, height = ButtonDefaults.MinHeight),
) {
Icon(
symbol = if (isMuted) MaterialSymbols.AutoMirrored.VolumeOff else MaterialSymbols.AutoMirrored.VolumeUp,
contentDescription = stringRes(if (isMuted) R.string.nest_mic_unmute else R.string.nest_mic_mute),
)
}
}
@Composable
private fun HandRaiseToggle(
handRaised: Boolean,
onToggle: (Boolean) -> Unit,
) {
FilledTonalIconToggleButton(
checked = handRaised,
onCheckedChange = onToggle,
) {
Icon(
symbol = MaterialSymbols.PanTool,
contentDescription = stringRes(if (handRaised) R.string.nest_lower_hand else R.string.nest_raise_hand),
)
}
}
@Composable
private fun LeaveRoomButton(onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.Logout,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.width(6.dp))
Text(stringRes(R.string.nest_leave))
}
}
@@ -462,3 +464,20 @@ private fun connectingLabel(connection: ConnectionUiState.Connecting): String =
ConnectionUiState.Step.OpeningTransport -> stringRes(R.string.nest_connecting_transport)
ConnectionUiState.Step.MoqHandshake -> stringRes(R.string.nest_connecting_handshake)
}
// ── Permission helpers ──────────────────────────────────────────────────
private fun Context.hasMicPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
private fun Context.openAppSettings() {
runCatching {
startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null),
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) },
)
}
}
+2 -2
View File
@@ -521,10 +521,10 @@
<string name="nest_mic_mute">Mute mic</string>
<string name="nest_mic_unmute">Unmute mic</string>
<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>