feat(audio-rooms): pre-roll buffering overlay on speaker avatars

Tracks the window between SUBSCRIBE_OK and the first decoded audio
frame (typically 0.5-2s on a fresh subscription) so the user can
see audio is on its way rather than wonder why a "live" speaker
is silent.

Wire-up:
  * AudioRoomUiState gains `connectingSpeakers: ImmutableSet<String>`,
    a set-once-per-subscription field.
  * VM enters the set on `slot.attach(...)` (right after SUBSCRIBE_OK
    succeeds), exits on the first `onSpeakerActivity` callback (first
    decoded packet), and clears on speaker removal. Set membership
    survives subsequent silence — once a speaker has delivered a
    frame, we know the pipeline works.
  * ParticipantsGrid renders a small CircularProgressIndicator
    overlaid on the avatar (sized smaller than the picture so the
    user stays recognisable underneath).

Audience-side avatars don't get the overlay — they have no MoQ
subscription. Only on-stage speakers in the connectingSpeakers set
show it.
This commit is contained in:
Claude
2026-04-27 01:39:30 +00:00
parent d9fe3b5f83
commit c3ff82913b
3 changed files with 58 additions and 10 deletions
@@ -206,6 +206,7 @@ internal fun AudioRoomFullScreen(
onStageLabel = stringRes(R.string.audio_room_stage),
audienceLabel = stringRes(R.string.audio_room_audience),
reactionsByPubkey = reactionsByPubkey,
connectingSpeakers = ui.connectingSpeakers,
onLongPressParticipant = onLongPressParticipant,
)
val speakerCatalogs by viewModel.speakerCatalogs.collectAsState()
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
@@ -72,6 +73,7 @@ internal fun ParticipantsGrid(
audienceLabel: String,
modifier: Modifier = Modifier,
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
connectingSpeakers: ImmutableSet<String> = kotlinx.collections.immutable.persistentSetOf(),
onLongPressParticipant: ((String) -> Unit)? = null,
) {
Column(modifier = modifier.fillMaxWidth()) {
@@ -81,6 +83,7 @@ internal fun ParticipantsGrid(
members = grid.onStage,
avatarSize = Size40dp,
speakingNow = speakingNow,
connectingSpeakers = connectingSpeakers,
accountViewModel = accountViewModel,
reactionsByPubkey = reactionsByPubkey,
onLongPressParticipant = onLongPressParticipant,
@@ -92,10 +95,11 @@ internal fun ParticipantsGrid(
title = audienceLabel,
members = grid.audience,
avatarSize = Size35dp,
// Audience rows don't get the speaking-ring — only
// members with a live broadcast track do. Pass an
// empty set rather than thread a per-section bool.
// Audience rows don't get the speaking-ring or
// buffering overlay — only members with a live
// broadcast subscription do.
speakingNow = kotlinx.collections.immutable.persistentSetOf(),
connectingSpeakers = kotlinx.collections.immutable.persistentSetOf(),
accountViewModel = accountViewModel,
reactionsByPubkey = emptyMap(),
onLongPressParticipant = onLongPressParticipant,
@@ -110,6 +114,7 @@ private fun ParticipantsSection(
members: List<RoomMember>,
avatarSize: androidx.compose.ui.unit.Dp,
speakingNow: ImmutableSet<String>,
connectingSpeakers: ImmutableSet<String>,
accountViewModel: AccountViewModel,
reactionsByPubkey: Map<String, List<RoomReaction>>,
onLongPressParticipant: ((String) -> Unit)?,
@@ -145,17 +150,33 @@ private fun ParticipantsSection(
com.vitorpamplona.amethyst.model.LocalCache
.getOrCreateUser(member.pubkey)
}
val isConnecting = member.pubkey in connectingSpeakers
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(avatarSize + 16.dp),
) {
ClickableUserPicture(
baseUserHex = member.pubkey,
size = avatarSize,
accountViewModel = accountViewModel,
modifier = avatarModifier,
onLongClick = onLongPressParticipant?.let { cb -> { hex -> cb(hex) } },
)
androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) {
ClickableUserPicture(
baseUserHex = member.pubkey,
size = avatarSize,
accountViewModel = accountViewModel,
modifier = avatarModifier,
onLongClick = onLongPressParticipant?.let { cb -> { hex -> cb(hex) } },
)
if (isConnecting) {
// Pre-roll buffering overlay — visible
// between SUBSCRIBE_OK and the first
// decoded frame (typically 0.5-2 s on a
// fresh subscription). Sized smaller than
// the avatar so the user picture stays
// recognisable underneath.
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(avatarSize - 8.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
}
}
com.vitorpamplona.amethyst.ui.note.UsernameDisplay(
baseUser = user,
weight = Modifier.fillMaxWidth(),
@@ -628,6 +628,9 @@ class AudioRoomViewModel(
if (_uiState.value.speakingNow.contains(slot.pubkey)) {
_uiState.update { it.copy(speakingNow = (it.speakingNow - slot.pubkey).toPersistentSet()) }
}
if (_uiState.value.connectingSpeakers.contains(slot.pubkey)) {
_uiState.update { it.copy(connectingSpeakers = (it.connectingSpeakers - slot.pubkey).toPersistentSet()) }
}
if (_speakerCatalogs.value.containsKey(slot.pubkey)) {
_speakerCatalogs.update { it - slot.pubkey }
}
@@ -710,6 +713,12 @@ class AudioRoomViewModel(
roomPlayer.play(instrumented, onError = { /* swallow per-packet decoder errors */ })
slot.attach(handle, roomPlayer, player)
publishActiveSpeakers()
// Enter the buffering window — UI renders a spinner
// overlay until the first audio frame triggers
// `onSpeakerActivity` and clears the entry.
_uiState.update {
it.copy(connectingSpeakers = (it.connectingSpeakers + pubkey).toPersistentSet())
}
} catch (t: Throwable) {
// Either CancellationException (scope cancelled mid-construction)
// or an unexpected throw — release the half-built pipeline and
@@ -792,6 +801,11 @@ class AudioRoomViewModel(
private fun onSpeakerActivity(pubkey: String) {
if (closed) return
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()) }
}
if (!_uiState.value.speakingNow.contains(pubkey)) {
_uiState.update { it.copy(speakingNow = (it.speakingNow + pubkey).toPersistentSet()) }
}
@@ -914,6 +928,18 @@ data class AudioRoomUiState(
val activeSpeakers: ImmutableSet<String> = persistentSetOf(),
/** Pubkeys whose audio track delivered an object in the last [SPEAKING_TIMEOUT_MS]. */
val speakingNow: ImmutableSet<String> = persistentSetOf(),
/**
* Pubkeys we have an open subscription for but no audio frame has
* arrived yet — the pre-roll window between SUBSCRIBE_OK and the
* first decoded packet. Typically 0.5-2 s on a fresh join. The UI
* shows a buffering overlay here so the user knows audio is on
* its way (vs the speaker sitting silent on stage).
*
* Membership is set-once-per-subscription: once a frame arrives
* the pubkey moves out of this set and stays out, even if the
* speaker subsequently goes quiet. Cleared on speaker removal.
*/
val connectingSpeakers: ImmutableSet<String> = persistentSetOf(),
/** Speaker / publisher state — only relevant when [AudioRoomViewModel.canBroadcast]. */
val broadcast: BroadcastUiState = BroadcastUiState.Idle,
/**