feat(nests): PIP focuses on active speakers, surfaces self + connection state

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
This commit is contained in:
Claude
2026-04-28 15:17:44 +00:00
parent 4756348221
commit 4c63e58e68
3 changed files with 213 additions and 18 deletions
@@ -197,6 +197,8 @@ private fun NestActivityBody(
title = event.room(),
onStage = onStage,
ui = ui,
viewModel = viewModel,
handRaised = handRaised,
accountViewModel = accountViewModel,
)
} else {
@@ -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
+1
View File
@@ -586,6 +586,7 @@
<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>