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:
+1
@@ -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()
|
||||
|
||||
+31
-10
@@ -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(),
|
||||
|
||||
+26
@@ -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,
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user