Merge pull request #2695 from vitorpamplona/claude/fix-duplicate-mic-buttons-AKgfT

fix: drop redundant StopBroadcastButton from nest action bar
This commit is contained in:
Vitor Pamplona
2026-05-01 18:20:19 -04:00
committed by GitHub
16 changed files with 197 additions and 105 deletions
@@ -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<Unit>(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 /
@@ -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.
@@ -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] [Stop] [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,57 @@ 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 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(
viewModel: NestViewModel,
ui: NestUiState,
isOnStage: Boolean,
canBroadcast: Boolean,
speakerPubkeyHex: String,
modifier: Modifier = Modifier,
) {
if (!isOnStage || !ui.onStageNow) return
Row(
modifier =
modifier
.fillMaxWidth()
.padding(top = 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 +208,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 +229,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
}
}
}
@@ -242,10 +265,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 +400,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.
@@ -246,13 +246,25 @@ internal fun NestFullScreen(
onTapSelf = onTapSelf,
listenerCount = presences.size,
modifier = Modifier.padding(horizontal = 16.dp),
// 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,
ui = ui,
isOnStage = isOnStageMe,
canBroadcast = viewModel.canBroadcast,
speakerPubkeyHex = myPubkey,
handRaised = handRaised,
onHandRaisedChange = onHandRaisedChange,
onShowReactionPicker = { showReactionPicker = true },
@@ -360,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()
}
},
@@ -371,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()
},
) {
@@ -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()
}
}
}
@@ -484,7 +484,6 @@
<string name="nest_audio_failed">Zvuk selhal: %1$s</string>
<string name="nest_audio_unavailable">Zvuk pro tuto místnost není k dispozici</string>
<string name="nest_talk">Mluvit</string>
<string name="nest_stop_talking">Přestat mluvit</string>
<string name="nest_leave_stage">Opustit pódium</string>
<string name="nest_mic_mute">Ztlumit mikrofon</string>
<string name="nest_mic_unmute">Zapnout mikrofon</string>
@@ -489,7 +489,6 @@ anz der Bedingungen ist erforderlich</string>
<string name="nest_audio_failed">Audio fehlgeschlagen: %1$s</string>
<string name="nest_audio_unavailable">Audio ist für diesen Raum nicht verfügbar</string>
<string name="nest_talk">Sprechen</string>
<string name="nest_stop_talking">Aufhören zu sprechen</string>
<string name="nest_leave_stage">Bühne verlassen</string>
<string name="nest_mic_mute">Mikrofon stumm</string>
<string name="nest_mic_unmute">Mikrofon aktivieren</string>
@@ -486,7 +486,6 @@
<string name="nest_audio_failed">Hanghiba: %1$s</string>
<string name="nest_audio_unavailable">A hang nem érhető el ebben a szobában</string>
<string name="nest_talk">Beszéd</string>
<string name="nest_stop_talking">Beszéd befejezése</string>
<string name="nest_leave_stage">Színpad elhagyása</string>
<string name="nest_mic_mute">Mikrofon némítása</string>
<string name="nest_mic_unmute">Mikrofon némításának megszüntetése</string>
@@ -460,7 +460,6 @@
<string name="nest_audio_failed">Audio mislukt: %1$s</string>
<string name="nest_audio_unavailable">Audio is niet beschikbaar voor deze ruimte</string>
<string name="nest_talk">Praten</string>
<string name="nest_stop_talking">Stoppen met praten</string>
<string name="nest_leave_stage">Podium verlaten</string>
<string name="nest_force_mute">Spreker forceren te dempen</string>
<string name="nest_kick_action">Kicken</string>
@@ -483,7 +483,6 @@
<string name="nest_audio_failed">Błąd dźwięku: %1$s</string>
<string name="nest_audio_unavailable">Dźwięk nie jest dostępny dla tego pokoju</string>
<string name="nest_talk">Mów</string>
<string name="nest_stop_talking">Przestań mówić</string>
<string name="nest_leave_stage">Zejść ze sceny</string>
<string name="nest_mic_mute">Wycisz mikrofon</string>
<string name="nest_mic_unmute">Włącz mikrofon</string>
@@ -484,7 +484,6 @@
<string name="nest_audio_failed">Áudio falhou: %1$s</string>
<string name="nest_audio_unavailable">O áudio não está disponível para esta sala</string>
<string name="nest_talk">Falar</string>
<string name="nest_stop_talking">Parar de falar</string>
<string name="nest_leave_stage">Sair do palco</string>
<string name="nest_mic_mute">Silenciar microfone</string>
<string name="nest_mic_unmute">Ativar microfone</string>
@@ -497,7 +497,6 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="nest_audio_failed">Napaka pri zvoku: %1$s</string>
<string name="nest_audio_unavailable">Zvok v tej sobi ni na voljo</string>
<string name="nest_talk">Govori</string>
<string name="nest_stop_talking">Prenehaj govoriti</string>
<string name="nest_leave_stage">Zapusti oder</string>
<string name="nest_mic_mute">Izklopi mikrofon</string>
<string name="nest_mic_unmute">Vklopi mikrofon</string>
@@ -484,7 +484,6 @@
<string name="nest_audio_failed">Ljudet misslyckades: %1$s</string>
<string name="nest_audio_unavailable">Ljud är inte tillgängligt för det här rummet</string>
<string name="nest_talk">Tala</string>
<string name="nest_stop_talking">Sluta tala</string>
<string name="nest_leave_stage">Lämna scenen</string>
<string name="nest_mic_mute">Stäng av mikrofon</string>
<string name="nest_mic_unmute">Slå på mikrofon</string>
@@ -486,7 +486,6 @@
<string name="nest_audio_failed">音频失败: %1$s</string>
<string name="nest_audio_unavailable">此聊天室没有音频可用</string>
<string name="nest_talk">交谈</string>
<string name="nest_stop_talking">停止交谈</string>
<string name="nest_leave_stage">离开舞台</string>
<string name="nest_mic_mute">麦克风静音</string>
<string name="nest_mic_unmute">取消麦克风静音</string>
-1
View File
@@ -516,7 +516,6 @@
<string name="nest_audio_failed">Audio failed: %1$s</string>
<string name="nest_audio_unavailable">Audio is not available for this room</string>
<string name="nest_talk">Talk</string>
<string name="nest_stop_talking">Stop talking</string>
<string name="nest_leave_stage">Leave stage</string>
<string name="nest_mic_mute">Mute mic</string>
<string name="nest_mic_unmute">Unmute mic</string>
@@ -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)