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