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:
+18
-3
@@ -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()) }
|
||||
|
||||
+19
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -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(
|
||||
|
||||
+210
-18
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user