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/NestActionBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt index 4363fd3d3..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 @@ -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. 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..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 @@ -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() }, ) { 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() } } } 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 af87760fa..959849ab3 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 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)