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) registerReceiver(pipReceiver, filter)
} }
val pipSupported =
packageManager.hasSystemFeature(android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE)
setContent { setContent {
AmethystTheme { AmethystTheme {
NestActivityContent( NestActivityContent(
addressValue = addressValue, addressValue = addressValue,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
isInPipMode = isInPipMode.value, isInPipMode = isInPipMode.value,
isPipSupported = pipSupported,
onMuteState = { muted -> onMuteState = { muted ->
if (isMuted.value != muted) { if (isMuted.value != muted) {
isMuted.value = muted isMuted.value = muted
@@ -148,6 +152,7 @@ class NestActivity : AppCompatActivity() {
onConnectedChange = { isConnected.value = it }, onConnectedChange = { isConnected.value = it },
pipMuteSignal = toggleMuteSignal, pipMuteSignal = toggleMuteSignal,
onLeave = { finish() }, onLeave = { finish() },
onMinimize = { enterPip() },
) )
} }
} }
@@ -155,9 +160,19 @@ class NestActivity : AppCompatActivity() {
override fun onUserLeaveHint() { override fun onUserLeaveHint() {
super.onUserLeaveHint() super.onUserLeaveHint()
// PIP only makes sense once the audio session is live; entering enterPip()
// PIP from the lobby/Connecting state would freeze a half-rendered }
// card in Recents. Also gates on the system supporting PIP.
/**
* 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 (!isConnected.value) return
if (!packageManager.hasSystemFeature(android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE)) return if (!packageManager.hasSystemFeature(android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE)) return
runCatching { enterPictureInPictureMode(buildPipParams()) } runCatching { enterPictureInPictureMode(buildPipParams()) }
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -28,6 +29,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.commons.model.AddressableNote 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.commons.viewmodels.NestViewModel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
@@ -58,10 +60,12 @@ internal fun NestActivityContent(
addressValue: String, addressValue: String,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
isInPipMode: Boolean, isInPipMode: Boolean,
isPipSupported: Boolean,
onMuteState: (Boolean) -> Unit, onMuteState: (Boolean) -> Unit,
onConnectedChange: (Boolean) -> Unit, onConnectedChange: (Boolean) -> Unit,
pipMuteSignal: SharedFlow<Unit>, pipMuteSignal: SharedFlow<Unit>,
onLeave: () -> Unit, onLeave: () -> Unit,
onMinimize: () -> Unit,
) { ) {
val parsedAddress = remember(addressValue) { Address.parse(addressValue) } val parsedAddress = remember(addressValue) { Address.parse(addressValue) }
if (parsedAddress == null) { if (parsedAddress == null) {
@@ -97,10 +101,12 @@ internal fun NestActivityContent(
viewModel = viewModel, viewModel = viewModel,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
isInPipMode = isInPipMode, isInPipMode = isInPipMode,
isPipSupported = isPipSupported,
onMuteState = onMuteState, onMuteState = onMuteState,
onConnectedChange = onConnectedChange, onConnectedChange = onConnectedChange,
pipMuteSignal = pipMuteSignal, pipMuteSignal = pipMuteSignal,
onLeave = onLeave, onLeave = onLeave,
onMinimize = onMinimize,
) )
} }
} }
@@ -125,10 +131,12 @@ private fun NestActivityBody(
viewModel: NestViewModel, viewModel: NestViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
isInPipMode: Boolean, isInPipMode: Boolean,
isPipSupported: Boolean,
onMuteState: (Boolean) -> Unit, onMuteState: (Boolean) -> Unit,
onConnectedChange: (Boolean) -> Unit, onConnectedChange: (Boolean) -> Unit,
pipMuteSignal: SharedFlow<Unit>, pipMuteSignal: SharedFlow<Unit>,
onLeave: () -> Unit, onLeave: () -> Unit,
onMinimize: () -> Unit,
) { ) {
val account = accountViewModel.account val account = accountViewModel.account
val localPubkey = account.signer.pubKey val localPubkey = account.signer.pubKey
@@ -177,6 +185,13 @@ private fun NestActivityBody(
handRaised = handRaised, 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) { if (isInPipMode) {
NestPipScreen( NestPipScreen(
title = event.room(), title = event.room(),
@@ -195,6 +210,8 @@ private fun NestActivityBody(
handRaised = handRaised, handRaised = handRaised,
onHandRaisedChange = { handRaised = it }, onHandRaisedChange = { handRaised = it },
onLeave = onLeave, onLeave = onLeave,
onMinimize = onMinimize,
canMinimize = isPipSupported,
) )
} }
} }
@@ -114,6 +114,8 @@ internal fun NestFullScreen(
handRaised: Boolean, handRaised: Boolean,
onHandRaisedChange: (Boolean) -> Unit, onHandRaisedChange: (Boolean) -> Unit,
onLeave: () -> Unit, onLeave: () -> Unit,
onMinimize: () -> Unit,
canMinimize: Boolean,
) { ) {
var showEditSheet by rememberSaveable { mutableStateOf(false) } var showEditSheet by rememberSaveable { mutableStateOf(false) }
var showHostMenu by rememberSaveable { mutableStateOf(false) } var showHostMenu by rememberSaveable { mutableStateOf(false) }
@@ -181,6 +183,8 @@ internal fun NestFullScreen(
title = event.room().orEmpty(), title = event.room().orEmpty(),
isHost = isHost, isHost = isHost,
showHostMenu = showHostMenu, showHostMenu = showHostMenu,
showMinimize = canMinimize,
onMinimize = onMinimize,
onMenuOpen = { showHostMenu = true }, onMenuOpen = { showHostMenu = true },
onMenuDismiss = { showHostMenu = false }, onMenuDismiss = { showHostMenu = false },
onShare = { onShare = {
@@ -483,6 +487,8 @@ private fun NestTopAppBar(
title: String, title: String,
isHost: Boolean, isHost: Boolean,
showHostMenu: Boolean, showHostMenu: Boolean,
showMinimize: Boolean,
onMinimize: () -> Unit,
onMenuOpen: () -> Unit, onMenuOpen: () -> Unit,
onMenuDismiss: () -> Unit, onMenuDismiss: () -> Unit,
onShare: () -> Unit, onShare: () -> Unit,
@@ -497,6 +503,18 @@ private fun NestTopAppBar(
) )
}, },
actions = { 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 { Box {
IconButton(onClick = onMenuOpen) { IconButton(onClick = onMenuOpen) {
Icon( Icon(
+2
View File
@@ -584,6 +584,8 @@
<string name="nest_participant_mute">Mute</string> <string name="nest_participant_mute">Mute</string>
<string name="nest_participant_unmute">Unmute</string> <string name="nest_participant_unmute">Unmute</string>
<string name="nest_share_action">Share room</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_create_fab">Start space</string>
<string name="nest_no_server_title">Set up a nest server</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> <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>