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),
|
onStageLabel = stringRes(R.string.audio_room_stage),
|
||||||
audienceLabel = stringRes(R.string.audio_room_audience),
|
audienceLabel = stringRes(R.string.audio_room_audience),
|
||||||
reactionsByPubkey = reactionsByPubkey,
|
reactionsByPubkey = reactionsByPubkey,
|
||||||
|
connectingSpeakers = ui.connectingSpeakers,
|
||||||
onLongPressParticipant = onLongPressParticipant,
|
onLongPressParticipant = onLongPressParticipant,
|
||||||
)
|
)
|
||||||
val speakerCatalogs by viewModel.speakerCatalogs.collectAsState()
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
|
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
|
||||||
@@ -72,6 +73,7 @@ internal fun ParticipantsGrid(
|
|||||||
audienceLabel: String,
|
audienceLabel: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
|
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
|
||||||
|
connectingSpeakers: ImmutableSet<String> = kotlinx.collections.immutable.persistentSetOf(),
|
||||||
onLongPressParticipant: ((String) -> Unit)? = null,
|
onLongPressParticipant: ((String) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
@@ -81,6 +83,7 @@ internal fun ParticipantsGrid(
|
|||||||
members = grid.onStage,
|
members = grid.onStage,
|
||||||
avatarSize = Size40dp,
|
avatarSize = Size40dp,
|
||||||
speakingNow = speakingNow,
|
speakingNow = speakingNow,
|
||||||
|
connectingSpeakers = connectingSpeakers,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
reactionsByPubkey = reactionsByPubkey,
|
reactionsByPubkey = reactionsByPubkey,
|
||||||
onLongPressParticipant = onLongPressParticipant,
|
onLongPressParticipant = onLongPressParticipant,
|
||||||
@@ -92,10 +95,11 @@ internal fun ParticipantsGrid(
|
|||||||
title = audienceLabel,
|
title = audienceLabel,
|
||||||
members = grid.audience,
|
members = grid.audience,
|
||||||
avatarSize = Size35dp,
|
avatarSize = Size35dp,
|
||||||
// Audience rows don't get the speaking-ring — only
|
// Audience rows don't get the speaking-ring or
|
||||||
// members with a live broadcast track do. Pass an
|
// buffering overlay — only members with a live
|
||||||
// empty set rather than thread a per-section bool.
|
// broadcast subscription do.
|
||||||
speakingNow = kotlinx.collections.immutable.persistentSetOf(),
|
speakingNow = kotlinx.collections.immutable.persistentSetOf(),
|
||||||
|
connectingSpeakers = kotlinx.collections.immutable.persistentSetOf(),
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
reactionsByPubkey = emptyMap(),
|
reactionsByPubkey = emptyMap(),
|
||||||
onLongPressParticipant = onLongPressParticipant,
|
onLongPressParticipant = onLongPressParticipant,
|
||||||
@@ -110,6 +114,7 @@ private fun ParticipantsSection(
|
|||||||
members: List<RoomMember>,
|
members: List<RoomMember>,
|
||||||
avatarSize: androidx.compose.ui.unit.Dp,
|
avatarSize: androidx.compose.ui.unit.Dp,
|
||||||
speakingNow: ImmutableSet<String>,
|
speakingNow: ImmutableSet<String>,
|
||||||
|
connectingSpeakers: ImmutableSet<String>,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
reactionsByPubkey: Map<String, List<RoomReaction>>,
|
reactionsByPubkey: Map<String, List<RoomReaction>>,
|
||||||
onLongPressParticipant: ((String) -> Unit)?,
|
onLongPressParticipant: ((String) -> Unit)?,
|
||||||
@@ -145,17 +150,33 @@ private fun ParticipantsSection(
|
|||||||
com.vitorpamplona.amethyst.model.LocalCache
|
com.vitorpamplona.amethyst.model.LocalCache
|
||||||
.getOrCreateUser(member.pubkey)
|
.getOrCreateUser(member.pubkey)
|
||||||
}
|
}
|
||||||
|
val isConnecting = member.pubkey in connectingSpeakers
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier.width(avatarSize + 16.dp),
|
modifier = Modifier.width(avatarSize + 16.dp),
|
||||||
) {
|
) {
|
||||||
ClickableUserPicture(
|
androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) {
|
||||||
baseUserHex = member.pubkey,
|
ClickableUserPicture(
|
||||||
size = avatarSize,
|
baseUserHex = member.pubkey,
|
||||||
accountViewModel = accountViewModel,
|
size = avatarSize,
|
||||||
modifier = avatarModifier,
|
accountViewModel = accountViewModel,
|
||||||
onLongClick = onLongPressParticipant?.let { cb -> { hex -> cb(hex) } },
|
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(
|
com.vitorpamplona.amethyst.ui.note.UsernameDisplay(
|
||||||
baseUser = user,
|
baseUser = user,
|
||||||
weight = Modifier.fillMaxWidth(),
|
weight = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
+26
@@ -628,6 +628,9 @@ class AudioRoomViewModel(
|
|||||||
if (_uiState.value.speakingNow.contains(slot.pubkey)) {
|
if (_uiState.value.speakingNow.contains(slot.pubkey)) {
|
||||||
_uiState.update { it.copy(speakingNow = (it.speakingNow - slot.pubkey).toPersistentSet()) }
|
_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)) {
|
if (_speakerCatalogs.value.containsKey(slot.pubkey)) {
|
||||||
_speakerCatalogs.update { it - slot.pubkey }
|
_speakerCatalogs.update { it - slot.pubkey }
|
||||||
}
|
}
|
||||||
@@ -710,6 +713,12 @@ class AudioRoomViewModel(
|
|||||||
roomPlayer.play(instrumented, onError = { /* swallow per-packet decoder errors */ })
|
roomPlayer.play(instrumented, onError = { /* swallow per-packet decoder errors */ })
|
||||||
slot.attach(handle, roomPlayer, player)
|
slot.attach(handle, roomPlayer, player)
|
||||||
publishActiveSpeakers()
|
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) {
|
} catch (t: Throwable) {
|
||||||
// Either CancellationException (scope cancelled mid-construction)
|
// Either CancellationException (scope cancelled mid-construction)
|
||||||
// or an unexpected throw — release the half-built pipeline and
|
// or an unexpected throw — release the half-built pipeline and
|
||||||
@@ -792,6 +801,11 @@ class AudioRoomViewModel(
|
|||||||
private fun onSpeakerActivity(pubkey: String) {
|
private fun onSpeakerActivity(pubkey: String) {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
speakingExpiryJobs[pubkey]?.cancel()
|
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)) {
|
if (!_uiState.value.speakingNow.contains(pubkey)) {
|
||||||
_uiState.update { it.copy(speakingNow = (it.speakingNow + pubkey).toPersistentSet()) }
|
_uiState.update { it.copy(speakingNow = (it.speakingNow + pubkey).toPersistentSet()) }
|
||||||
}
|
}
|
||||||
@@ -914,6 +928,18 @@ data class AudioRoomUiState(
|
|||||||
val activeSpeakers: ImmutableSet<String> = persistentSetOf(),
|
val activeSpeakers: ImmutableSet<String> = persistentSetOf(),
|
||||||
/** Pubkeys whose audio track delivered an object in the last [SPEAKING_TIMEOUT_MS]. */
|
/** Pubkeys whose audio track delivered an object in the last [SPEAKING_TIMEOUT_MS]. */
|
||||||
val speakingNow: ImmutableSet<String> = persistentSetOf(),
|
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]. */
|
/** Speaker / publisher state — only relevant when [AudioRoomViewModel.canBroadcast]. */
|
||||||
val broadcast: BroadcastUiState = BroadcastUiState.Idle,
|
val broadcast: BroadcastUiState = BroadcastUiState.Idle,
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user