From b86219b1e1f69bf7d5707f865ea88896291a9e2b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 17:57:45 +0000 Subject: [PATCH 1/4] fix(nests): match mute toggle height with action bar buttons Material3 expressive defaults the FilledTonalIconToggleButton container shorter than ButtonDefaults.MinHeight, leaving the audience listen-mute and on-stage mic-mute toggles visibly shorter than the neighboring "Leave the Stage" / "Leave" outlined buttons. Pin the toggle height to ButtonDefaults.MinHeight so the row aligns. --- .../ui/screen/loggedIn/nests/room/screen/NestActionBar.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt index cb8b522db..dbc0a9566 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt @@ -213,6 +213,7 @@ private fun StartCluster( FilledTonalIconToggleButton( checked = ui.isMuted, onCheckedChange = { viewModel.setMuted(it) }, + modifier = Modifier.size(width = 40.dp, height = ButtonDefaults.MinHeight), ) { Icon( symbol = @@ -340,6 +341,7 @@ private fun OnStageControls( FilledTonalIconToggleButton( checked = broadcast.isMuted, onCheckedChange = { viewModel.setMicMuted(it) }, + modifier = Modifier.size(width = 40.dp, height = ButtonDefaults.MinHeight), ) { Icon( symbol = From 640e7904e4138afe2de565cb8b41ac6781b66e63 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 19:02:25 +0000 Subject: [PATCH 2/4] fix(nests): tighten action bar state coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../nests/room/screen/NestActionBar.kt | 70 +++++++++++-------- amethyst/src/main/res/values/strings.xml | 3 +- 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt index dbc0a9566..4c72d5fb6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt @@ -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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a2e65e8dd..679340089 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -523,8 +523,9 @@ Going live… Live Broadcast failed: %1$s + Mute failed: %1$s Microphone access is required to talk in this room. - Open settings + No permissions Nests Keeps audio playing while a room is open. Nest connected From 7be8ac510675934dd3ae37eee5caeb7974dbf3ab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 19:14:17 +0000 Subject: [PATCH 3/4] fix(nests): drop redundant Live chip while broadcasting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The red Mic-on FilledIconButton already conveys "you are live, tap to stop" — the AssistChip labelled "Live" beside it was duplicate visual noise. Removed the chip and its now-orphan string. Mute toggle + red Mic + Leave the Stage are sufficient to express the broadcasting state. --- .../screen/loggedIn/nests/room/screen/NestActionBar.kt | 10 ---------- amethyst/src/main/res/values/strings.xml | 1 - 2 files changed, 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt index 4c72d5fb6..b26facf66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt @@ -34,7 +34,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 @@ -338,15 +337,6 @@ private fun OnStageControls( } 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) }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 679340089..34774ee7f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -521,7 +521,6 @@ Mute mic Unmute mic Going live… - Live Broadcast failed: %1$s Mute failed: %1$s Microphone access is required to talk in this room. From 951f6c37f0d15b71f32405c3e88f62b09354c062 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 19:22:52 +0000 Subject: [PATCH 4/4] refactor(nests): extract action bar affordances and refresh KDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NestActionBar.kt had grown to ~470 lines with most of the volume in OnStageControls, where four broadcast-state branches each inlined a 56dp filled icon button, a tonal toggle button, and an outlined "Leave the Stage" button. The result was hard to scan for state coverage, and the top-level KDoc still described the pre-cleanup layout. Changes: * Rewrote the top-level KDoc as a state-by-state table matching the current UI. * Extracted reusable affordances: ConnectButton, StatusChip, LeaveStageButton, TalkButton, StopBroadcastButton, MicMuteToggle, HandRaiseToggle, LeaveRoomButton. * Merged the duplicate Connect button branches in StartCluster (Idle / Closed / Failed all share one). The failure reason already appears in the status strip above. * Split OnStageControls: kept the dispatcher thin and moved the permission state + Settings deep-link into OnStageIdleControls, which is the only state that needs them. * Replaced the inline qualified Settings Intent with a Context.openAppSettings() helper, plus a Context.hasMicPermission() helper for the two RECORD_AUDIO checks. * Pulled the status-strip text resolution into a small NestUiState.statusStripText() so ActionBarStatusStrip is one Text. No behavior changes — just structure, naming, and docs. --- .../nests/room/screen/NestActionBar.kt | 523 +++++++++--------- 1 file changed, 268 insertions(+), 255 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt index b26facf66..163216a1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt @@ -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 @@ -65,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( @@ -99,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), ) { @@ -136,33 +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) - } - - is BroadcastUiState.Broadcasting -> { - broadcast.muteError?.let { - stringRes(R.string.nest_mute_failed, it) - } - } - - else -> { - null - } - } - } - } - if (text == null) return + val text = ui.statusStripText() ?: return Text( text = text, style = MaterialTheme.typography.bodySmall, @@ -171,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, @@ -184,26 +175,19 @@ 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 -> { @@ -211,31 +195,22 @@ private fun StartCluster( canBroadcast && isOnStage -> { OnStageControls( viewModel = viewModel, - ui = ui, + broadcast = ui.broadcast, speakerPubkeyHex = speakerPubkeyHex, ) } + // On stage but no signing/permission — only thing we can do is step down. 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)) - } + LeaveStageButton(onClick = { viewModel.setOnStage(false) }) } + // Audience: nothing here. Phone volume keys cover local volume. else -> { - // Audience: nothing to do here. System volume keys cover - // local volume; the mute toggle was redundant. + Unit } } } - - is ConnectionUiState.Failed -> { - Button(onClick = { viewModel.connect() }) { - Text(stringRes(R.string.nest_connect)) - } - } } } } @@ -243,163 +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)) }, - ) - // 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 -> { - FilledTonalIconToggleButton( - checked = broadcast.isMuted, - onCheckedChange = { viewModel.setMicMuted(it) }, - modifier = Modifier.size(width = 40.dp, height = ButtonDefaults.MinHeight), - ) { - 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), - ) - } - OutlinedButton(onClick = { viewModel.setOnStage(false) }) { - Text(stringRes(R.string.nest_leave_stage)) + 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)) } } } @@ -417,25 +307,13 @@ private fun EndCluster( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - // 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. + // Hand-raise: only meaningful for connected audience. On stage + // it's moot; disconnected it can't reach the room. if (!isOnStage && isConnected) { - FilledTonalIconToggleButton( - checked = handRaised, - onCheckedChange = onHandRaisedChange, - ) { - Icon( - symbol = MaterialSymbols.PanTool, - contentDescription = - stringRes( - if (handRaised) R.string.nest_lower_hand else R.string.nest_raise_hand, - ), - ) - } + 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, @@ -443,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)) } } @@ -468,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) }, + ) + } +}