From 47563482212793b5edce72fbf9eb950a4e2fec00 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 15:03:34 +0000 Subject: [PATCH] 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 --- .../nests/room/activity/NestActivity.kt | 21 ++++++++++++++++--- .../room/activity/NestActivityContent.kt | 17 +++++++++++++++ .../nests/room/screen/NestFullScreen.kt | 18 ++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 ++ 4 files changed, 55 insertions(+), 3 deletions(-) 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 eef107332..f722fbe43 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 @@ -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()) } 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 f303ad83a..8de828d42 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 @@ -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, 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, 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, ) } } 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 49e11db44..de4aa99ce 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 @@ -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( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0fc165d1d..5e524de79 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -584,6 +584,8 @@ Mute Unmute Share room + Minimize + Minimize to keep listening Start space Set up a nest server 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.