mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
@@ -420,28 +421,18 @@ private fun MemberCell(
|
|||||||
},
|
},
|
||||||
label = "speaker-outer-ring-width",
|
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 =
|
val avatarModifier =
|
||||||
Modifier
|
Modifier
|
||||||
.drawBehind {
|
.border(animatedRingWidth, animatedRingColor, CircleShape)
|
||||||
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)
|
|
||||||
.let { if (member.absent) it.alpha(0.5f) else it }
|
.let { if (member.absent) it.alpha(0.5f) else it }
|
||||||
val user =
|
val user =
|
||||||
remember(member.pubkey) {
|
remember(member.pubkey) {
|
||||||
@@ -480,58 +471,53 @@ private fun MemberCell(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
// Outer Box paints the glow halo + detached outer ring on a
|
||||||
ClickableUserPicture(
|
// canvas that's bigger than the avatar by [ringPadding]. The
|
||||||
baseUserHex = member.pubkey,
|
// inner Box keeps its tight-to-avatar bounds so badge corner
|
||||||
size = avatarSize,
|
// alignment (TopStart, TopEnd, BottomCenter, BottomEnd) still
|
||||||
accountViewModel = accountViewModel,
|
// tracks the avatar circle, not the padded outer area.
|
||||||
modifier = avatarModifier,
|
Box(
|
||||||
onClick = onClick,
|
modifier =
|
||||||
onLongClick = onLongClick,
|
Modifier.drawBehind {
|
||||||
)
|
val avatarRadiusPx = avatarSize.toPx() / 2f
|
||||||
if (isConnecting) {
|
val cx = size.width / 2f
|
||||||
CircularProgressIndicator(
|
val cy = size.height / 2f
|
||||||
modifier = Modifier.size(avatarSize - 8.dp),
|
if (animatedGlowAlpha > 0.001f) {
|
||||||
strokeWidth = 2.dp,
|
val extra = MAX_GLOW_RADIUS.toPx() * clampedLevel
|
||||||
color = MaterialTheme.colorScheme.primary,
|
drawCircle(
|
||||||
)
|
color = NEST_SPEAKING_COLOR.copy(alpha = animatedGlowAlpha),
|
||||||
}
|
radius = avatarRadiusPx + extra,
|
||||||
val role = member.role
|
center = Offset(cx, cy),
|
||||||
if (role == ROLE.HOST || role == ROLE.MODERATOR) {
|
)
|
||||||
RoleBadge(
|
}
|
||||||
role = role,
|
if (animatedOuterRingAlpha > 0.001f && animatedOuterRingWidth > 0.dp) {
|
||||||
modifier = Modifier.align(Alignment.TopStart),
|
val strokePx = animatedOuterRingWidth.toPx()
|
||||||
)
|
val ringRadius = avatarRadiusPx + OUTER_RING_GAP.toPx() + strokePx / 2f
|
||||||
}
|
drawCircle(
|
||||||
if (member.handRaised) {
|
color = NEST_SPEAKING_COLOR.copy(alpha = animatedOuterRingAlpha),
|
||||||
HandRaiseBadge(
|
radius = ringRadius,
|
||||||
modifier = Modifier.align(Alignment.TopEnd),
|
center = Offset(cx, cy),
|
||||||
)
|
style = Stroke(width = strokePx),
|
||||||
}
|
)
|
||||||
// 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`).
|
contentAlignment = Alignment.Center,
|
||||||
// Gating only on `publishing` would hide the muted icon
|
) {
|
||||||
// the moment the user mutes, which is exactly when it's
|
Box(
|
||||||
// supposed to appear.
|
modifier = Modifier.padding(ringPadding),
|
||||||
if (showMicBadge && (member.publishing || member.muted == true)) {
|
contentAlignment = Alignment.Center,
|
||||||
MicStateBadge(
|
) {
|
||||||
|
AvatarAndBadges(
|
||||||
|
member = member,
|
||||||
|
avatarSize = avatarSize,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
avatarModifier = avatarModifier,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
isConnecting = isConnecting,
|
||||||
|
showMicBadge = showMicBadge,
|
||||||
isSpeaking = isSpeaking,
|
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,
|
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
|
* Hand-raise indicator overlaid on the avatar — yellow circle with
|
||||||
* a hand glyph at the top-right, animated in a subtle vertical
|
* 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]
|
* Per-frame heartbeat. Called once per MoQ object received on the
|
||||||
* coroutine that clears it once they go quiet. Called once per
|
* speaker's track — i.e. once per ~20 ms regardless of whether
|
||||||
* MoQ object received on the speaker's track.
|
* 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) {
|
private fun onSpeakerActivity(pubkey: String) {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
@@ -1513,12 +1521,22 @@ class NestViewModel(
|
|||||||
// future re-stall starts from attempt 0 (immediate-fire) rather
|
// future re-stall starts from attempt 0 (immediate-fire) rather
|
||||||
// than inheriting a long backoff from the prior streak.
|
// than inheriting a long backoff from the prior streak.
|
||||||
if (consecutiveCliffRecycles != 0) consecutiveCliffRecycles = 0
|
if (consecutiveCliffRecycles != 0) consecutiveCliffRecycles = 0
|
||||||
speakingExpiryJobs[pubkey]?.cancel()
|
|
||||||
// First frame for this subscription — clear the buffering
|
// First frame for this subscription — clear the buffering
|
||||||
// overlay. Subsequent frames are no-ops here.
|
// overlay. Subsequent frames are no-ops here.
|
||||||
if (_uiState.value.connectingSpeakers.contains(pubkey)) {
|
if (_uiState.value.connectingSpeakers.contains(pubkey)) {
|
||||||
_uiState.update { it.copy(connectingSpeakers = (it.connectingSpeakers - pubkey).toPersistentSet()) }
|
_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)) {
|
if (!_uiState.value.speakingNow.contains(pubkey)) {
|
||||||
_uiState.update { it.copy(speakingNow = (it.speakingNow + pubkey).toPersistentSet()) }
|
_uiState.update { it.copy(speakingNow = (it.speakingNow + pubkey).toPersistentSet()) }
|
||||||
}
|
}
|
||||||
@@ -1681,6 +1699,20 @@ class NestViewModel(
|
|||||||
) {
|
) {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
rawAudioLevels[pubkey] = level
|
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()
|
startLevelEmitter()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1839,12 +1871,29 @@ sealed class BroadcastUiState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long a speaker stays "speaking" after their last received MoQ object.
|
* How long a speaker stays "speaking" after their last decoded frame
|
||||||
* Roughly 12 × the 20 ms Opus frame so brief packet jitter doesn't make the
|
* over the [SPEAKING_LEVEL_THRESHOLD]. Roughly 12 × the 20 ms Opus
|
||||||
* indicator flicker.
|
* 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
|
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
|
* How long [NestViewModel.openSubscription] waits for the publisher's
|
||||||
* `catalog.json` to land before constructing the decoder + AudioTrack.
|
* `catalog.json` to land before constructing the decoder + AudioTrack.
|
||||||
|
|||||||
Reference in New Issue
Block a user