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, /**