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:
+126
-70
@@ -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
|
||||
|
||||
+56
-7
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user