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:
+2
@@ -197,6 +197,8 @@ private fun NestActivityBody(
|
||||
title = event.room(),
|
||||
onStage = onStage,
|
||||
ui = ui,
|
||||
viewModel = viewModel,
|
||||
handRaised = handRaised,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
} else {
|
||||
|
||||
+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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user