From e7cb6dae5f0c7e055c958d9bdc3eb5678ae9562a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 20:39:11 +0000 Subject: [PATCH 1/6] fix: drop redundant StopBroadcastButton from nest action bar MicMuteToggle already covers 'stop sending audio while staying on stage' with sample-accurate resume, and LeaveStageButton covers full teardown. StopBroadcastButton drew a Mic icon next to MicMuteToggle's Mic icon, reading visually as two adjacent mic buttons. --- .../nests/room/screen/NestActionBar.kt | 29 +------------------ .../src/main/res/values-cs-rCZ/strings.xml | 1 - .../src/main/res/values-de-rDE/strings.xml | 1 - .../src/main/res/values-hu-rHU/strings.xml | 1 - .../src/main/res/values-nl-rNL/strings.xml | 1 - .../src/main/res/values-pl-rPL/strings.xml | 1 - .../src/main/res/values-pt-rBR/strings.xml | 1 - .../src/main/res/values-sl-rSI/strings.xml | 1 - .../src/main/res/values-sv-rSE/strings.xml | 1 - .../src/main/res/values-zh-rCN/strings.xml | 1 - amethyst/src/main/res/values/strings.xml | 1 - 11 files changed, 1 insertion(+), 38 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 4363fd3d3..816dd75d6 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 @@ -84,7 +84,7 @@ import com.vitorpamplona.amethyst.ui.stringRes * | 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, broadcasting | `[MicMute] [Leave the Stage]` | * | Connected, on stage, broadcast failed | `[Retry] [Leave the Stage]` | * * End cluster: hand-raise (audience + connected only), react, leave room. @@ -242,10 +242,6 @@ private fun OnStageControls( is BroadcastUiState.Broadcasting -> { MicMuteToggle(isMuted = broadcast.isMuted, onToggle = viewModel::setMicMuted) - // Stop sending audio without leaving the stage — viewer - // can pause the mic and resume later via Talk. Distinct - // from [LeaveStageButton], which also vacates the slot. - StopBroadcastButton(onClick = viewModel::stopBroadcast) LeaveStageButton(onClick = leaveStage) } @@ -381,29 +377,6 @@ private fun TalkButton( } } -/** - * 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. diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 3bfbf0de2..1b508f200 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -484,7 +484,6 @@ Zvuk selhal: %1$s Zvuk pro tuto místnost není k dispozici Mluvit - Přestat mluvit Opustit pódium Ztlumit mikrofon Zapnout mikrofon diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 963e6569b..c9142ab85 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -489,7 +489,6 @@ anz der Bedingungen ist erforderlich Audio fehlgeschlagen: %1$s Audio ist für diesen Raum nicht verfügbar Sprechen - Aufhören zu sprechen Bühne verlassen Mikrofon stumm Mikrofon aktivieren diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 0b18ebab6..05b0a77bf 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -486,7 +486,6 @@ Hanghiba: %1$s A hang nem érhető el ebben a szobában Beszéd - Beszéd befejezése Színpad elhagyása Mikrofon némítása Mikrofon némításának megszüntetése diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 8cfff7d11..69e497589 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -460,7 +460,6 @@ Audio mislukt: %1$s Audio is niet beschikbaar voor deze ruimte Praten - Stoppen met praten Podium verlaten Spreker forceren te dempen Kicken diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 7ffedcc28..c27737cf8 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -483,7 +483,6 @@ Błąd dźwięku: %1$s Dźwięk nie jest dostępny dla tego pokoju Mów - Przestań mówić Zejść ze sceny Wycisz mikrofon Włącz mikrofon diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index ded5dccca..db09bd5e7 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -484,7 +484,6 @@ Áudio falhou: %1$s O áudio não está disponível para esta sala Falar - Parar de falar Sair do palco Silenciar microfone Ativar microfone diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 756aade6e..2f58f1582 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -497,7 +497,6 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Napaka pri zvoku: %1$s Zvok v tej sobi ni na voljo Govori - Prenehaj govoriti Zapusti oder Izklopi mikrofon Vklopi mikrofon diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index db38000ae..0a6b72dbc 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -484,7 +484,6 @@ Ljudet misslyckades: %1$s Ljud är inte tillgängligt för det här rummet Tala - Sluta tala Lämna scenen Stäng av mikrofon Slå på mikrofon diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 8d5cbb0ed..b620ce38c 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -486,7 +486,6 @@ 音频失败: %1$s 此聊天室没有音频可用 交谈 - 停止交谈 离开舞台 麦克风静音 取消麦克风静音 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index caf123df0..42845ad6b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -516,7 +516,6 @@ Audio failed: %1$s Audio is not available for this room Talk - Stop talking Leave stage Mute mic Unmute mic From 9c26c51a1bc26c9655181b913ca629b445ec6428 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 20:57:07 +0000 Subject: [PATCH 2/6] fix: move on-stage controls out of action bar onto stage card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../nests/room/screen/NestActionBar.kt | 99 +++++++++++-------- .../nests/room/screen/NestFullScreen.kt | 12 ++- 2 files changed, 68 insertions(+), 43 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 816dd75d6..c723c2491 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 @@ -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]` | + * | State | Start cluster contents | + * |------------------------------------|------------------------| + * | Idle / Closed / Failed connection | `[Connect]` | + * | 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,26 +221,10 @@ 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 - } - } + Unit } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt index c5ccc9bfd..32fb19579 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt @@ -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 }, From c981058a9f8dbe4d1b524604beabc43a65c92187 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 21:16:38 +0000 Subject: [PATCH 3/6] fix: render StageControlsBar inside the Stage card surface The previous commit placed the controls strip as a sibling row right after StageGrid. Visually it read as a separate band, not as part of the stage card. Adds a bottomBar slot to StageGrid that renders inside the same Surface, below the grid, sharing the card's tonal background and horizontal padding. StageControlsBar drops its own horizontal padding since the card supplies it, and keeps a top inset so it has breathing room from the avatars. --- .../nests/room/screen/NestActionBar.kt | 2 +- .../nests/room/screen/NestFullScreen.kt | 26 ++++----- .../nests/room/stage/ParticipantsGrid.kt | 54 ++++++++++--------- 3 files changed, 45 insertions(+), 37 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 c723c2491..ce8bb9c9b 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 @@ -154,7 +154,7 @@ internal fun StageControlsBar( modifier = modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt index 32fb19579..ed6f90ea2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt @@ -246,18 +246,20 @@ internal fun NestFullScreen( onTapSelf = onTapSelf, listenerCount = presences.size, modifier = Modifier.padding(horizontal = 16.dp), - ) - // 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, + // Speaker controls live inside 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. + bottomBar = { + StageControlsBar( + viewModel = viewModel, + ui = ui, + isOnStage = isOnStageMe, + canBroadcast = viewModel.canBroadcast, + speakerPubkeyHex = myPubkey, + ) + }, ) NestActionBar( viewModel = viewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt index 01ecb4181..0e8efdefb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt @@ -126,6 +126,10 @@ private val STAGE_MAX_HEIGHT = 320.dp * * When [members] is empty, renders an "Waiting for speakers…" hint * instead so the strip stays visually anchored. + * + * [bottomBar] is rendered inside the same Surface, below the grid — + * used to anchor speaker controls to the stage card so they only + * appear when the local user is on stage. */ @Composable internal fun StageGrid( @@ -140,6 +144,7 @@ internal fun StageGrid( myPubkey: String? = null, onTapSelf: (() -> Unit)? = null, listenerCount: Int = 0, + bottomBar: (@Composable () -> Unit)? = null, ) { // Float currently-speaking members to the top so the listener can // see who they're hearing without scrolling. sortedBy is stable in @@ -193,32 +198,33 @@ internal fun StageGrid( } if (members.isEmpty()) { EmptyStageHint() - return@Column - } - LazyVerticalGrid( - columns = GridCells.Adaptive(STAGE_CELL_MIN), - modifier = Modifier.fillMaxWidth().heightIn(max = STAGE_MAX_HEIGHT), - horizontalArrangement = Arrangement.spacedBy(GRID_SPACING), - verticalArrangement = Arrangement.spacedBy(GRID_SPACING), - ) { - items(items = sortedMembers, key = { it.pubkey }) { member -> - val isSelf = myPubkey != null && member.pubkey == myPubkey - MemberCell( - member = member, - avatarSize = STAGE_AVATAR, - isSpeaking = member.pubkey in speakingNow, - audioLevel = audioLevels[member.pubkey] ?: 0f, - isConnecting = member.pubkey in connectingSpeakers, - showMicBadge = true, - reactions = reactionsByPubkey[member.pubkey].orEmpty(), - accountViewModel = accountViewModel, - onLongPressParticipant = onLongPressParticipant, - isSelf = isSelf, - onTapSelf = if (isSelf) onTapSelf else null, - modifier = Modifier.animateItem(), - ) + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(STAGE_CELL_MIN), + modifier = Modifier.fillMaxWidth().heightIn(max = STAGE_MAX_HEIGHT), + horizontalArrangement = Arrangement.spacedBy(GRID_SPACING), + verticalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + items(items = sortedMembers, key = { it.pubkey }) { member -> + val isSelf = myPubkey != null && member.pubkey == myPubkey + MemberCell( + member = member, + avatarSize = STAGE_AVATAR, + isSpeaking = member.pubkey in speakingNow, + audioLevel = audioLevels[member.pubkey] ?: 0f, + isConnecting = member.pubkey in connectingSpeakers, + showMicBadge = true, + reactions = reactionsByPubkey[member.pubkey].orEmpty(), + accountViewModel = accountViewModel, + onLongPressParticipant = onLongPressParticipant, + isSelf = isSelf, + onTapSelf = if (isSelf) onTapSelf else null, + modifier = Modifier.animateItem(), + ) + } } } + bottomBar?.invoke() } } } From 6610683a20362aeda3a446c476a4bd6618181535 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 21:30:05 +0000 Subject: [PATCH 4/6] fix: hide StageControlsBar on local intent flag for responsive tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After tapping Leave the Stage, the bar lingered because it was gated solely on isOnStageMe — a value derived from the presence aggregator. That aggregator only updates after a presence event signs, broadcasts to relays, and loops back through LocalCache. With the recent broadcast / mute traffic contending on the signer plus the 500 ms mute debounce in NestPresencePublisher, the round-trip ran long enough that the user perceived Leave the Stage as not firing. AND the visibility gate with ui.onStageNow (the local intent flag, which flips synchronously on setOnStage(false)) so the tap hides the bar on the next frame. Host demotion still hides it via the existing isOnStageMe path. --- .../nests/room/screen/NestActionBar.kt | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 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 ce8bb9c9b..4270f79c2 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 @@ -133,12 +133,20 @@ internal fun NestActionBar( * 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). + * Visibility uses two signals AND'd together: + * - [isOnStage] — derived from `participantGrid.onStage` (kind-30312 + * role + presence `onstage` flag); flips on a real promote/demote + * after the round-trip lands. Connection blips, broadcast state + * churn, mute toggles, and permission flows do NOT change it. + * - [ui.onStageNow] — the LOCAL intent flag. Flips synchronously on + * `setOnStage(false)` so a Leave-the-Stage tap hides the bar on + * the next frame, instead of waiting for the presence event to + * sign, broadcast, and loop back through LocalCache (a delay made + * visible in scenario "broadcast → mute → unmute → leave", where + * the signer queues behind a pending mute frame + 500 ms debounce). + * + * AND-of-both also handles host demotion correctly: intent stays true + * but the aggregator drops us, so the bar still hides. */ @Composable internal fun StageControlsBar( @@ -149,7 +157,7 @@ internal fun StageControlsBar( speakerPubkeyHex: String, modifier: Modifier = Modifier, ) { - if (!isOnStage) return + if (!isOnStage || !ui.onStageNow) return Row( modifier = modifier From 36152243abc86abb07bfe9c2b2154071b74e9b1a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 21:47:33 +0000 Subject: [PATCH 5/6] fix: tear down nest session on host Close the Room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host-leave confirmation dialog's Close the Room button only called finish(), relying on VM.onCleared() to release the AudioRecord. That runs late in the destroy lifecycle, so the system mic-in-use indicator stayed lit while the activity was queued for destruction. Adds NestViewModel.leave(), which mirrors onCleared() (sets closed, runs both teardowns with finalCleanup=true so closes route through cleanupScope/GlobalScope and survive Activity destruction). Wires it into the Close the Room callback only — the Just Leave and non-host Leave paths are unchanged per request. --- .../nests/room/screen/NestFullScreen.kt | 11 +++++++++++ .../commons/viewmodels/NestViewModel.kt | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt index ed6f90ea2..4e01702aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt @@ -372,6 +372,17 @@ internal fun NestFullScreen( R.string.nest_leave_host_close_failed, ) } + // Tear down the speaker session + listener + // BEFORE finishing the activity so the + // AudioRecord (and the system mic indicator) + // releases promptly. onCleared() alone runs + // late in the destroy lifecycle and can + // leave the mic held while the activity is + // queued for destruction. Only the + // "Close the Room" path triggers this — the + // "Just leave" and non-host leave paths + // still rely on onCleared. + viewModel.leave() onLeave() } }, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt index e9c64fddf..60c7e6769 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt @@ -545,6 +545,24 @@ class NestViewModel( teardown(targetState = ConnectionUiState.Idle, finalCleanup = false) } + /** + * User-driven leave-the-activity teardown. Mirrors [onCleared]: marks + * the VM closed and routes both the speaker handle close (which + * releases the AudioRecord and clears the system mic indicator) and + * the listener close to [cleanupScope] so the teardown survives + * Activity destruction. Call this from the leave-the-room button + * BEFORE finishing the activity; relying on [onCleared] alone leaves + * a window where the AudioRecord stays held while the activity is + * paused / queued for destruction. + */ + fun leave() { + if (closed) return + closed = true + requestedSpeakers = emptySet() + teardownBroadcast(BroadcastUiState.Idle, finalCleanup = true) + teardown(targetState = ConnectionUiState.Closed, finalCleanup = true) + } + override fun onCleared() { closed = true teardownBroadcast(BroadcastUiState.Idle, finalCleanup = true) From 770af8faf9466b483b65e245a787bc43b9dd98ba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 22:05:23 +0000 Subject: [PATCH 6/6] fix: tear down nest session on PIP close + Just Leave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more leave paths now fully release the speaker session and listener (and with them AudioRecord + the system mic indicator) before the Activity destruction queue eats viewModelScope: - PIP overlay's Leave action — pipReceiver invokes a cleanup callback set by NestActivityBody, then finish(). - PIP swipe-dismiss — NestActivity.onStop() invokes the same callback when in PIP and isFinishing. Non-PIP onStop stays a no-op so backgrounding doesn't tear down. - Host's Just leave — calls viewModel.leave() before onLeave(). Unlike Close the Room, this still leaves the kind-30312 meeting space open for other users. The cleanup callback is registered via DisposableEffect in NestActivityBody so it's auto-cleared when the composable leaves. --- .../nests/room/activity/NestActivity.kt | 35 +++++++++++++++++++ .../room/activity/NestActivityContent.kt | 15 ++++++++ .../nests/room/screen/NestFullScreen.kt | 9 +++++ 3 files changed, 59 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivity.kt index 7721f0114..1ff53d481 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivity.kt @@ -83,12 +83,31 @@ class NestActivity : AppCompatActivity() { } ACTION_PIP_LEAVE -> { + // Tear down the speaker session + listener BEFORE + // finish() so AudioRecord (and the system mic + // indicator) releases promptly. onCleared() alone + // runs late in the destroy lifecycle. + pipCleanupAction?.invoke() finish() } } } } + /** + * Wired by [NestActivityBody] via a DisposableEffect. Invokes + * `viewModel.leave()` so the broadcast handle + listener close + * route through `cleanupScope`/GlobalScope and survive Activity + * destruction. Called from PIP close paths (the overlay's Leave + * action and a swipe-dismiss while in PIP). + */ + @Volatile + private var pipCleanupAction: (() -> Unit)? = null + + fun setPipCleanupAction(action: (() -> Unit)?) { + pipCleanupAction = action + } + private val _toggleMuteSignal = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) @@ -180,6 +199,22 @@ class NestActivity : AppCompatActivity() { enterPip() } + /** + * Detects a PIP swipe-dismiss. When the user dismisses the floating + * window the system finishes the activity, so [isFinishing] is true + * by the time onStop runs. Tearing down here (rather than waiting + * for onCleared) keeps AudioRecord + the system mic indicator from + * lingering through the destruction queue. Non-PIP onStop (Home / + * Recents while not in PIP) is a no-op so backgrounding doesn't + * teardown. + */ + override fun onStop() { + if (isInPipMode.value && isFinishing) { + pipCleanupAction?.invoke() + } + super.onStop() + } + /** * Enter Picture-in-Picture if the audio session is live and the * system supports PIP. Called both by [onUserLeaveHint] (Home / diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt index 11d37caf6..065e38e25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -39,6 +40,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R @@ -274,6 +276,19 @@ private fun NestActivityBody( PipBridge(ui, pipMuteSignal, viewModel, onMuteState, onConnectedChange) NestForegroundServiceLifecycle(ui) + // Tell NestActivity how to tear the session down when the user + // closes PIP. Both close paths in PIP — the overlay's Leave + // action and a swipe-dismiss while in PIP — invoke this so the + // speaker handle close (releases AudioRecord and clears the + // system mic indicator) and the listener close run on + // cleanupScope/GlobalScope before the activity destruction + // queue eats viewModelScope. + val activity = LocalContext.current as? NestActivity + DisposableEffect(activity, viewModel) { + activity?.setPipCleanupAction { viewModel.leave() } + onDispose { activity?.setPipCleanupAction(null) } + } + // Hand-raise is screen-local UI state; presence-publish picks it // up via the heartbeat and emits onstage / muted / publishing // alongside. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt index 4e01702aa..77c241ec8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt @@ -394,6 +394,15 @@ internal fun NestFullScreen( TextButton( onClick = { showHostLeaveConfirm = false + // Tear down the speaker session + listener before + // finishing so the AudioRecord (and the system mic + // indicator) releases promptly. Same reasoning as + // the Close the Room path — onCleared() runs late + // in the destroy lifecycle and can leave the mic + // held while the activity is queued for destruction. + // Unlike Close the Room, this path leaves the + // kind-30312 meeting space open for other users. + viewModel.leave() onLeave() }, ) {