Merge pull request #2753 from vitorpamplona/claude/fix-green-circle-ui-H7zsK

Fix avatar glow/ring clipping and energy-gate speaking indicator
This commit is contained in:
Vitor Pamplona
2026-05-06 18:25:46 -04:00
committed by GitHub
2 changed files with 182 additions and 77 deletions
@@ -57,6 +57,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.pluralStringResource
@@ -420,28 +421,18 @@ private fun MemberCell(
},
label = "speaker-outer-ring-width",
)
// Reserve enough space around the avatar Box to fit the outer ring
// and glow halo. Without this padding the rings clip against the
// surrounding Surface / LazyVerticalGrid bounds (most visibly at
// the top edge for the first row, where the stage card's rounded
// corner cuts into the glow). The glow extends up to MAX_GLOW_RADIUS
// past the avatar; the outer ring extends OUTER_RING_GAP +
// OUTER_RING_MAX_WIDTH past it.
val ringPadding =
maxOf(MAX_GLOW_RADIUS.value, (OUTER_RING_GAP + OUTER_RING_MAX_WIDTH).value).dp
val avatarModifier =
Modifier
.drawBehind {
if (animatedGlowAlpha > 0.001f) {
val baseRadius = size.minDimension / 2f
val extra = MAX_GLOW_RADIUS.toPx() * clampedLevel
drawCircle(
color = NEST_SPEAKING_COLOR.copy(alpha = animatedGlowAlpha),
radius = baseRadius + extra,
)
}
if (animatedOuterRingAlpha > 0.001f && animatedOuterRingWidth > 0.dp) {
val baseRadius = size.minDimension / 2f
val strokePx = animatedOuterRingWidth.toPx()
val ringRadius = baseRadius + OUTER_RING_GAP.toPx() + strokePx / 2f
drawCircle(
color = NEST_SPEAKING_COLOR.copy(alpha = animatedOuterRingAlpha),
radius = ringRadius,
style = Stroke(width = strokePx),
)
}
}.border(animatedRingWidth, animatedRingColor, CircleShape)
.border(animatedRingWidth, animatedRingColor, CircleShape)
.let { if (member.absent) it.alpha(0.5f) else it }
val user =
remember(member.pubkey) {
@@ -480,58 +471,53 @@ private fun MemberCell(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Box(contentAlignment = Alignment.Center) {
ClickableUserPicture(
baseUserHex = member.pubkey,
size = avatarSize,
accountViewModel = accountViewModel,
modifier = avatarModifier,
onClick = onClick,
onLongClick = onLongClick,
)
if (isConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(avatarSize - 8.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
}
val role = member.role
if (role == ROLE.HOST || role == ROLE.MODERATOR) {
RoleBadge(
role = role,
modifier = Modifier.align(Alignment.TopStart),
)
}
if (member.handRaised) {
HandRaiseBadge(
modifier = Modifier.align(Alignment.TopEnd),
)
}
// Show the mic badge for any on-stage speaker that has
// an audio state to surface — currently broadcasting
// (`publishing=1`) OR mic-muted (`muted=1, publishing=0`).
// Gating only on `publishing` would hide the muted icon
// the moment the user mutes, which is exactly when it's
// supposed to appear.
if (showMicBadge && (member.publishing || member.muted == true)) {
MicStateBadge(
// Outer Box paints the glow halo + detached outer ring on a
// canvas that's bigger than the avatar by [ringPadding]. The
// inner Box keeps its tight-to-avatar bounds so badge corner
// alignment (TopStart, TopEnd, BottomCenter, BottomEnd) still
// tracks the avatar circle, not the padded outer area.
Box(
modifier =
Modifier.drawBehind {
val avatarRadiusPx = avatarSize.toPx() / 2f
val cx = size.width / 2f
val cy = size.height / 2f
if (animatedGlowAlpha > 0.001f) {
val extra = MAX_GLOW_RADIUS.toPx() * clampedLevel
drawCircle(
color = NEST_SPEAKING_COLOR.copy(alpha = animatedGlowAlpha),
radius = avatarRadiusPx + extra,
center = Offset(cx, cy),
)
}
if (animatedOuterRingAlpha > 0.001f && animatedOuterRingWidth > 0.dp) {
val strokePx = animatedOuterRingWidth.toPx()
val ringRadius = avatarRadiusPx + OUTER_RING_GAP.toPx() + strokePx / 2f
drawCircle(
color = NEST_SPEAKING_COLOR.copy(alpha = animatedOuterRingAlpha),
radius = ringRadius,
center = Offset(cx, cy),
style = Stroke(width = strokePx),
)
}
},
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier.padding(ringPadding),
contentAlignment = Alignment.Center,
) {
AvatarAndBadges(
member = member,
avatarSize = avatarSize,
accountViewModel = accountViewModel,
avatarModifier = avatarModifier,
onClick = onClick,
onLongClick = onLongClick,
isConnecting = isConnecting,
showMicBadge = showMicBadge,
isSpeaking = isSpeaking,
isMuted = member.muted == true,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
// Reactions float over the avatar's bottom-right corner so
// a 👏 burst no longer pushes the username down and reflows
// neighbouring cells. The mic badge sits at BottomCenter,
// so BottomEnd + a small outward offset keeps them clear.
if (reactions.isNotEmpty()) {
SpeakerReactionOverlay(
reactions = reactions,
modifier =
Modifier
.align(Alignment.BottomEnd)
.offset(x = 6.dp, y = 6.dp),
)
}
}
@@ -545,6 +531,76 @@ private fun MemberCell(
}
}
@Composable
private fun AvatarAndBadges(
member: RoomMember,
avatarSize: Dp,
accountViewModel: AccountViewModel,
avatarModifier: Modifier,
onClick: ((String) -> Unit)?,
onLongClick: ((String) -> Unit)?,
isConnecting: Boolean,
showMicBadge: Boolean,
isSpeaking: Boolean,
reactions: List<RoomReaction>,
) {
Box(contentAlignment = Alignment.Center) {
ClickableUserPicture(
baseUserHex = member.pubkey,
size = avatarSize,
accountViewModel = accountViewModel,
modifier = avatarModifier,
onClick = onClick,
onLongClick = onLongClick,
)
if (isConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(avatarSize - 8.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
}
val role = member.role
if (role == ROLE.HOST || role == ROLE.MODERATOR) {
RoleBadge(
role = role,
modifier = Modifier.align(Alignment.TopStart),
)
}
if (member.handRaised) {
HandRaiseBadge(
modifier = Modifier.align(Alignment.TopEnd),
)
}
// Show the mic badge for any on-stage speaker that has
// an audio state to surface — currently broadcasting
// (`publishing=1`) OR mic-muted (`muted=1, publishing=0`).
// Gating only on `publishing` would hide the muted icon
// the moment the user mutes, which is exactly when it's
// supposed to appear.
if (showMicBadge && (member.publishing || member.muted == true)) {
MicStateBadge(
isSpeaking = isSpeaking,
isMuted = member.muted == true,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
// Reactions float over the avatar's bottom-right corner so
// a 👏 burst no longer pushes the username down and reflows
// neighbouring cells. The mic badge sits at BottomCenter,
// so BottomEnd + a small outward offset keeps them clear.
if (reactions.isNotEmpty()) {
SpeakerReactionOverlay(
reactions = reactions,
modifier =
Modifier
.align(Alignment.BottomEnd)
.offset(x = 6.dp, y = 6.dp),
)
}
}
}
/**
* Hand-raise indicator overlaid on the avatar — yellow circle with
* a hand glyph at the top-right, animated in a subtle vertical
@@ -1494,9 +1494,17 @@ class NestViewModel(
}
/**
* Mark [pubkey] as currently speaking and (re)arm a [SPEAKING_TIMEOUT_MS]
* coroutine that clears it once they go quiet. Called once per
* MoQ object received on the speaker's track.
* Per-frame heartbeat. Called once per MoQ object received on the
* speaker's track — i.e. once per ~20 ms regardless of whether
* that frame contains actual speech, silence, or background noise.
*
* Bumps the cliff-detector timestamp so an active stream keeps
* resetting the relay-forward-queue stall watchdog, and clears
* the per-speaker buffering overlay the first time a frame lands.
*
* NB: this does NOT mark the speaker as "speaking right now".
* That signal is energy-gated and lives in [onAudioLevel] —
* mic-on with no voice MUST NOT light up the green ring.
*/
private fun onSpeakerActivity(pubkey: String) {
if (closed) return
@@ -1513,12 +1521,22 @@ class NestViewModel(
// future re-stall starts from attempt 0 (immediate-fire) rather
// than inheriting a long backoff from the prior streak.
if (consecutiveCliffRecycles != 0) consecutiveCliffRecycles = 0
speakingExpiryJobs[pubkey]?.cancel()
// First frame for this subscription — clear the buffering
// overlay. Subsequent frames are no-ops here.
if (_uiState.value.connectingSpeakers.contains(pubkey)) {
_uiState.update { it.copy(connectingSpeakers = (it.connectingSpeakers - pubkey).toPersistentSet()) }
}
}
/**
* Mark [pubkey] as currently speaking and (re)arm a
* [SPEAKING_TIMEOUT_MS] coroutine that clears the flag once their
* audio drops back below the threshold for that long. Called from
* [onAudioLevel] only when the decoded peak is loud enough to
* read as speech (see [SPEAKING_LEVEL_THRESHOLD]).
*/
private fun markSpeaking(pubkey: String) {
speakingExpiryJobs[pubkey]?.cancel()
if (!_uiState.value.speakingNow.contains(pubkey)) {
_uiState.update { it.copy(speakingNow = (it.speakingNow + pubkey).toPersistentSet()) }
}
@@ -1681,6 +1699,20 @@ class NestViewModel(
) {
if (closed) return
rawAudioLevels[pubkey] = level
// Energy-gated speaking detector. The MoQ track delivers a
// frame every ~20 ms while the mic is open, even when the
// speaker is silent or only picking up room noise — gating the
// green ring on "frame arrived" therefore lights it up the
// moment the mic is unmuted, not when there's actually a voice
// on it. The decoded peak amplitude (`peakAmplitude` in
// nestsClient/audio/Amplitude.kt) gives us the signal we need:
// background noise / breath stays under a few percent of full
// scale, while even a quiet voice clears [SPEAKING_LEVEL_THRESHOLD].
// The 250 ms expiry already wired up in [markSpeaking] gives
// the indicator natural hysteresis between syllables.
if (level >= SPEAKING_LEVEL_THRESHOLD) {
markSpeaking(pubkey)
}
startLevelEmitter()
}
@@ -1839,12 +1871,29 @@ sealed class BroadcastUiState {
}
/**
* How long a speaker stays "speaking" after their last received MoQ object.
* Roughly 12 × the 20 ms Opus frame so brief packet jitter doesn't make the
* indicator flicker.
* How long a speaker stays "speaking" after their last decoded frame
* over the [SPEAKING_LEVEL_THRESHOLD]. Roughly 12 × the 20 ms Opus
* frame so brief packet jitter and inter-syllable pauses don't make
* the indicator flicker between adjacent words.
*/
const val SPEAKING_TIMEOUT_MS: Long = 250L
/**
* Minimum decoded peak amplitude (normalized to `[0, 1]`) that counts
* as "this person is actually speaking right now". Frames whose peak
* lands below this threshold are treated as silence / room tone /
* breath — they keep the per-speaker subscription healthy (the cliff
* heartbeat in [NestViewModel.onSpeakerActivity] still fires) but do
* NOT light up the green speaking ring.
*
* 0.06 ≈ -24 dBFS, comfortably above the typical residential-mic
* noise floor (~-40 to -30 dBFS) while still tripping on a quiet
* voice. Tuned in conjunction with [SPEAKING_TIMEOUT_MS]: a single
* loud frame is enough to arm the indicator; ≥ 250 ms below the
* threshold drops it.
*/
const val SPEAKING_LEVEL_THRESHOLD: Float = 0.06f
/**
* How long [NestViewModel.openSubscription] waits for the publisher's
* `catalog.json` to land before constructing the decoder + AudioTrack.