From c3ff82913b7a63b60174d640cc78364b796af2f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 01:39:30 +0000 Subject: [PATCH] feat(audio-rooms): pre-roll buffering overlay on speaker avatars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`, 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. --- .../audiorooms/room/AudioRoomFullScreen.kt | 1 + .../audiorooms/room/ParticipantsGrid.kt | 41 ++++++++++++++----- .../commons/viewmodels/AudioRoomViewModel.kt | 26 ++++++++++++ 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt index 91c6c9491..cd692f477 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantsGrid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantsGrid.kt index 7ae9fcb13..9f85d0487 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantsGrid.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantsGrid.kt @@ -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> = emptyMap(), + connectingSpeakers: ImmutableSet = 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, avatarSize: androidx.compose.ui.unit.Dp, speakingNow: ImmutableSet, + connectingSpeakers: ImmutableSet, accountViewModel: AccountViewModel, reactionsByPubkey: Map>, 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(), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt index 7b0b17a42..e264555b7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt @@ -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 = persistentSetOf(), /** Pubkeys whose audio track delivered an object in the last [SPEAKING_TIMEOUT_MS]. */ val speakingNow: ImmutableSet = 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 = persistentSetOf(), /** Speaker / publisher state — only relevant when [AudioRoomViewModel.canBroadcast]. */ val broadcast: BroadcastUiState = BroadcastUiState.Idle, /**