From 47563482212793b5edce72fbf9eb950a4e2fec00 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 15:03:34 +0000 Subject: [PATCH 1/2] 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. From 4c63e58e687bf21c7f3ed3a6e886437713bb5e25 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 15:17:44 +0000 Subject: [PATCH 2/2] feat(nests): PIP focuses on active speakers, surfaces self + connection state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls three high-signal features from NestScreen into the PIP layout without crowding the small floating window: - Audio-level pulsing rings on active speakers, mirroring the full-screen StageGrid contract. The ring width tracks live audio level so the PIP visibly responds to who is talking, instead of showing static borders. - Adaptive speaker selection: when anyone is speaking, PIP shows only the active speakers (up to 4); when exactly one person is talking the avatar bumps to 56dp for a clear focus mode. With no one speaking, falls back to the first 4 on-stage participants so the PIP isn't blank during silence. Fixes the 'same 4 faces while someone else talks off-screen' problem in big rooms. - Self-status row (mic broadcasting / muted, hand-raised), so a glance at the PIP answers 'am I muted?' without expanding back. Hidden for pure audience listeners with no hand to keep clutter low. - Reconnecting / Failed caption over a dimmed body so audio drops are obvious — previously the avatars stayed lit even with no audio arriving. https://claude.ai/code/session_01BpwAxN9gs79CJuWKqf3BEB --- .../room/activity/NestActivityContent.kt | 2 + .../nests/room/screen/NestPipScreen.kt | 228 ++++++++++++++++-- amethyst/src/main/res/values/strings.xml | 1 + 3 files changed, 213 insertions(+), 18 deletions(-) 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 8de828d42..d71e6b4dd 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 @@ -197,6 +197,8 @@ private fun NestActivityBody( title = event.room(), onStage = onStage, ui = ui, + viewModel = viewModel, + handRaised = handRaised, accountViewModel = accountViewModel, ) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestPipScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestPipScreen.kt index d7d528275..d3df7c75e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestPipScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestPipScreen.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -28,24 +30,55 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState +import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState +import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag /** - * Compact PIP layout. Shows up to four "on stage" speakers with a ring on - * whoever is actively delivering audio, plus the room title. The mute / - * leave actions live in the system PIP overlay as RemoteActions — see + * Compact PIP layout. Renders three things, in priority order: + * + * 1. Whoever is talking right now (or — if no one is — the first + * handful of on-stage participants so the user still sees who + * is on stage). Active speakers carry a green ring whose width + * tracks live audio level, mirroring the full-screen + * [com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.StageGrid]. + * In a 30-speaker room this means PIP focuses on whoever is + * actually delivering audio rather than showing the same four + * faces while someone else talks off-screen. + * 2. The user's own status (broadcasting / muted / hand-raised), + * so glancing at the PIP answers "am I muted?" without + * expanding back to the full screen. + * 3. Connection drops — if the wrapper is reconnecting or the + * session failed, the avatars dim and a centered status caption + * surfaces the new state instead of leaving the user staring at + * frozen avatars. + * + * Mute / leave actions live in the system PIP overlay as + * [android.app.RemoteAction]s — see * [com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity.buildPipActions]. */ @Composable @@ -53,9 +86,37 @@ internal fun NestPipScreen( title: String?, onStage: List, ui: NestUiState, + viewModel: NestViewModel, + handRaised: Boolean, accountViewModel: AccountViewModel, ) { - val ringColor = MaterialTheme.colorScheme.primary + val audioLevels by viewModel.audioLevels.collectAsState() + + // Active speakers in stage order. We deliberately *don't* show + // off-stage speakers in PIP — host kicks / role drops would race + // with the speakingNow set and produce ghost avatars. + val onStageNowSpeaking = + remember(onStage, ui.speakingNow) { + onStage.filter { it.pubKey in ui.speakingNow } + } + val focused = + if (onStageNowSpeaking.isNotEmpty()) { + onStageNowSpeaking.take(MAX_PIP_AVATARS) + } else { + // Nothing is being delivered right now. Keep the PIP visible + // (rather than blank) by falling back to the first on-stage + // participants — so the user still sees who's on stage + // during silence. + onStage.take(MAX_PIP_AVATARS) + } + val isFocusMode = onStageNowSpeaking.size == 1 + val avatarSize = if (isFocusMode) FOCUS_AVATAR_SIZE else GRID_AVATAR_SIZE + + val connection = ui.connection + val showReconnecting = connection is ConnectionUiState.Reconnecting + val showFailed = connection is ConnectionUiState.Failed + val dimBody = showReconnecting || showFailed + Box( modifier = Modifier @@ -76,25 +137,156 @@ internal fun NestPipScreen( overflow = TextOverflow.Ellipsis, ) } - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - onStage.take(MAX_PIP_AVATARS).forEach { participant -> - val isSpeaking = participant.pubKey in ui.speakingNow - val avatarModifier = - if (isSpeaking) { - Modifier.border(2.dp, ringColor, CircleShape) - } else { - Modifier - } - ClickableUserPicture( - baseUserHex = participant.pubKey, - size = Size35dp, + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.alpha(if (dimBody) DIMMED_ALPHA else 1f), + ) { + focused.forEach { participant -> + PipAvatar( + pubkey = participant.pubKey, + size = avatarSize, + isSpeaking = participant.pubKey in ui.speakingNow, + isConnecting = participant.pubKey in ui.connectingSpeakers, + audioLevel = audioLevels[participant.pubKey] ?: 0f, accountViewModel = accountViewModel, - modifier = avatarModifier, ) } } + PipSelfStatus( + broadcast = ui.broadcast, + handRaised = handRaised, + ) + } + + // Connection-state caption sits above the dimmed body so a + // dropped session is obvious without expanding the PIP. Failed + // uses the error palette; Reconnecting reads as transient. + if (showReconnecting) { + Text( + text = stringRes(R.string.nest_reconnecting), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.align(Alignment.Center), + ) + } else if (showFailed) { + Text( + text = stringRes(R.string.nest_audio_dropped), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.align(Alignment.Center), + ) + } + } +} + +/** + * Compact speaker tile. Same tri-state ring contract as the full-screen + * stage cell ([com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.StageGrid]) + * but tuned for ~35-56dp avatars: smaller ring, no outer glow halo + * (which would extend past the PIP bounds), no badges (no room). + */ +@Composable +private fun PipAvatar( + pubkey: String, + size: Dp, + isSpeaking: Boolean, + isConnecting: Boolean, + audioLevel: Float, + accountViewModel: AccountViewModel, +) { + val clampedLevel = audioLevel.coerceIn(0f, 1f) + val targetRingColor = if (isSpeaking) PIP_SPEAKING_COLOR else Color.Transparent + val targetRingWidth = + if (isSpeaking) { + PIP_RING_WIDTH + (clampedLevel * (PIP_MAX_RING_WIDTH - PIP_RING_WIDTH).value).dp + } else { + 0.dp + } + val animatedRingWidth by animateDpAsState(targetValue = targetRingWidth, label = "pip-ring-width") + val animatedRingColor by animateColorAsState(targetValue = targetRingColor, label = "pip-ring-color") + + Box(contentAlignment = Alignment.Center) { + ClickableUserPicture( + baseUserHex = pubkey, + size = size, + accountViewModel = accountViewModel, + modifier = Modifier.border(animatedRingWidth, animatedRingColor, CircleShape), + ) + if (isConnecting) { + // Pre-roll spinner: same contract as the stage cell — the + // speaker has an open subscription but no audio frame has + // arrived yet. Sized inside the avatar so it doesn't push + // the row layout. + CircularProgressIndicator( + modifier = Modifier.size(size - 6.dp), + strokeWidth = 1.5.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + } +} + +/** + * Self-state strip rendered below the speaker row. Kept invisible + * unless there's something the user might want to know — pure-audience + * listeners with no hand raised see no row at all (no clutter). + * + * Mic state mirrors the full-screen action bar: green when actively + * publishing, error red when broadcasting muted. Hand-raised is shown + * in the primary palette so it doesn't compete with the mute warning. + */ +@Composable +private fun PipSelfStatus( + broadcast: BroadcastUiState, + handRaised: Boolean, +) { + val broadcasting = broadcast as? BroadcastUiState.Broadcasting + if (broadcasting == null && !handRaised) return + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (broadcasting != null) { + val isMuted = broadcasting.isMuted + Icon( + symbol = if (isMuted) MaterialSymbols.MicOff else MaterialSymbols.Mic, + contentDescription = + stringRes(if (isMuted) R.string.nest_mic_mute else R.string.nest_mic_unmute), + tint = if (isMuted) MaterialTheme.colorScheme.error else PIP_SPEAKING_COLOR, + modifier = Modifier.size(STATUS_ICON_SIZE), + ) + } + if (handRaised) { + Icon( + symbol = MaterialSymbols.PanTool, + contentDescription = stringRes(R.string.nest_lower_hand), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(STATUS_ICON_SIZE), + ) } } } private const val MAX_PIP_AVATARS = 4 + +// Avatar diameter for the typical multi-speaker / silence case. 35dp +// matches the previous PIP layout so the visual change is "rings start +// pulsing" rather than a layout jump. +private val GRID_AVATAR_SIZE = 35.dp + +// Used when exactly one person is talking — bumps the avatar up so +// the focus mode reads as "this person, right now." Kept under +// half the typical PIP height so the title + status row still fit. +private val FOCUS_AVATAR_SIZE = 56.dp + +private val PIP_RING_WIDTH = 2.dp +private val PIP_MAX_RING_WIDTH = 4.dp + +// Same green as NEST_SPEAKING_COLOR in the full-screen StageGrid +// (tailwind green-500). Duplicated here rather than exporting the +// other constant so the two screens stay decoupled. +private val PIP_SPEAKING_COLOR = Color(0xFF22C55E) + +private val STATUS_ICON_SIZE = 14.dp + +private const val DIMMED_ALPHA = 0.4f diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5e524de79..53cb1277c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -586,6 +586,7 @@ Share room Minimize Minimize to keep listening + Audio dropped 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.