Merge pull request #2625 from vitorpamplona/claude/review-pip-transition-ui-2UYbc

Enhance Nest PIP with dynamic speaker focus and connection status
This commit is contained in:
Vitor Pamplona
2026-04-28 11:22:50 -04:00
committed by GitHub
5 changed files with 268 additions and 21 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,11 +185,20 @@ 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(),
onStage = onStage,
ui = ui,
viewModel = viewModel,
handRaised = handRaised,
accountViewModel = accountViewModel,
)
} else {
@@ -195,6 +212,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(
@@ -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<ParticipantTag>,
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
+3
View File
@@ -584,6 +584,9 @@
<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_audio_dropped">Audio dropped</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>