feat(nests): back gesture and Minimize button enter PiP instead of leaving

The only way to keep audio playing while leaving the room screen was the
Home/Recents gesture (onUserLeaveHint). Back swiped the user out of the
session entirely, and there was no in-UI affordance signalling that PiP
existed. Users wanting to step away had no obvious path that preserved
audio.

- BackHandler in NestActivityContent: while connected and PIP is
  supported, back minimizes to PIP. Disconnected/Connecting/Failed
  fall through to the default finish() so cancelling still works.
- Minimize IconButton in the room TopAppBar (hidden when the device
  doesn't advertise FEATURE_PICTURE_IN_PICTURE).
- Public NestActivity.enterPip() consolidates the connected + feature
  guards so onUserLeaveHint, BackHandler, and the Minimize button all
  share one entry point.

https://claude.ai/code/session_01BpwAxN9gs79CJuWKqf3BEB
This commit is contained in:
Claude
2026-04-28 15:03:34 +00:00
parent 30e34a408a
commit 4756348221
4 changed files with 55 additions and 3 deletions
@@ -133,12 +133,16 @@ class NestActivity : AppCompatActivity() {
registerReceiver(pipReceiver, filter)
}
val pipSupported =
packageManager.hasSystemFeature(android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE)
setContent {
AmethystTheme {
NestActivityContent(
addressValue = addressValue,
accountViewModel = accountViewModel,
isInPipMode = isInPipMode.value,
isPipSupported = pipSupported,
onMuteState = { muted ->
if (isMuted.value != muted) {
isMuted.value = muted
@@ -148,6 +152,7 @@ class NestActivity : AppCompatActivity() {
onConnectedChange = { isConnected.value = it },
pipMuteSignal = toggleMuteSignal,
onLeave = { finish() },
onMinimize = { enterPip() },
)
}
}
@@ -155,9 +160,19 @@ class NestActivity : AppCompatActivity() {
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// PIP only makes sense once the audio session is live; entering
// PIP from the lobby/Connecting state would freeze a half-rendered
// card in Recents. Also gates on the system supporting PIP.
enterPip()
}
/**
* Enter Picture-in-Picture if the audio session is live and the
* system supports PIP. Called both by [onUserLeaveHint] (Home /
* Recents gesture) and by user-driven entry points (back gesture,
* Minimize button in [com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen.NestFullScreen]).
*
* Gated on `isConnected` so PIP from the lobby/Connecting state
* doesn't freeze a half-rendered card in Recents.
*/
fun enterPip() {
if (!isConnected.value) return
if (!packageManager.hasSystemFeature(android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE)) return
runCatching { enterPictureInPictureMode(buildPipParams()) }
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -28,6 +29,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
@@ -58,10 +60,12 @@ internal fun NestActivityContent(
addressValue: String,
accountViewModel: AccountViewModel,
isInPipMode: Boolean,
isPipSupported: Boolean,
onMuteState: (Boolean) -> Unit,
onConnectedChange: (Boolean) -> Unit,
pipMuteSignal: SharedFlow<Unit>,
onLeave: () -> Unit,
onMinimize: () -> Unit,
) {
val parsedAddress = remember(addressValue) { Address.parse(addressValue) }
if (parsedAddress == null) {
@@ -97,10 +101,12 @@ internal fun NestActivityContent(
viewModel = viewModel,
accountViewModel = accountViewModel,
isInPipMode = isInPipMode,
isPipSupported = isPipSupported,
onMuteState = onMuteState,
onConnectedChange = onConnectedChange,
pipMuteSignal = pipMuteSignal,
onLeave = onLeave,
onMinimize = onMinimize,
)
}
}
@@ -125,10 +131,12 @@ private fun NestActivityBody(
viewModel: NestViewModel,
accountViewModel: AccountViewModel,
isInPipMode: Boolean,
isPipSupported: Boolean,
onMuteState: (Boolean) -> Unit,
onConnectedChange: (Boolean) -> Unit,
pipMuteSignal: SharedFlow<Unit>,
onLeave: () -> Unit,
onMinimize: () -> Unit,
) {
val account = accountViewModel.account
val localPubkey = account.signer.pubKey
@@ -177,6 +185,13 @@ private fun NestActivityBody(
handRaised = handRaised,
)
// Back gesture while connected = minimize to PIP rather than tearing
// down the audio session. When disconnected (lobby / Connecting /
// Failed) or PIP isn't supported, we let back fall through to the
// default Activity.finish() so the user can cancel out cleanly.
val canMinimize = isPipSupported && !isInPipMode && ui.connection is ConnectionUiState.Connected
BackHandler(enabled = canMinimize) { onMinimize() }
if (isInPipMode) {
NestPipScreen(
title = event.room(),
@@ -195,6 +210,8 @@ private fun NestActivityBody(
handRaised = handRaised,
onHandRaisedChange = { handRaised = it },
onLeave = onLeave,
onMinimize = onMinimize,
canMinimize = isPipSupported,
)
}
}
@@ -114,6 +114,8 @@ internal fun NestFullScreen(
handRaised: Boolean,
onHandRaisedChange: (Boolean) -> Unit,
onLeave: () -> Unit,
onMinimize: () -> Unit,
canMinimize: Boolean,
) {
var showEditSheet by rememberSaveable { mutableStateOf(false) }
var showHostMenu by rememberSaveable { mutableStateOf(false) }
@@ -181,6 +183,8 @@ internal fun NestFullScreen(
title = event.room().orEmpty(),
isHost = isHost,
showHostMenu = showHostMenu,
showMinimize = canMinimize,
onMinimize = onMinimize,
onMenuOpen = { showHostMenu = true },
onMenuDismiss = { showHostMenu = false },
onShare = {
@@ -483,6 +487,8 @@ private fun NestTopAppBar(
title: String,
isHost: Boolean,
showHostMenu: Boolean,
showMinimize: Boolean,
onMinimize: () -> Unit,
onMenuOpen: () -> Unit,
onMenuDismiss: () -> Unit,
onShare: () -> Unit,
@@ -497,6 +503,18 @@ private fun NestTopAppBar(
)
},
actions = {
// Minimize-to-PIP affordance. The user-facing way to learn
// that audio keeps playing in a floating window — pairs
// with the back-gesture handler in NestActivityContent.
// Hidden when the device doesn't advertise the PIP feature.
if (showMinimize) {
IconButton(onClick = onMinimize) {
Icon(
symbol = MaterialSymbols.PictureInPicture,
contentDescription = stringRes(R.string.nest_minimize_description),
)
}
}
Box {
IconButton(onClick = onMenuOpen) {
Icon(
+2
View File
@@ -584,6 +584,8 @@
<string name="nest_participant_mute">Mute</string>
<string name="nest_participant_unmute">Unmute</string>
<string name="nest_share_action">Share room</string>
<string name="nest_minimize">Minimize</string>
<string name="nest_minimize_description">Minimize to keep listening</string>
<string name="nest_create_fab">Start space</string>
<string name="nest_no_server_title">Set up a nest server</string>
<string name="nest_no_server_body">You haven\'t picked a nest server yet. Add %1$s to your server list and continue?\n\nYou can change this later in Settings.</string>