feat(audio-rooms): M2 multi-speaker subscribe + per-speaker speaking indicator

Listener side now subscribes to every host AND speaker on the stage (was
just hosts) and exposes a `speakingNow: ImmutableSet<String>` derived
from MoQ object arrival. Each on-stage avatar gets a primary-color ring
while its track is delivering audio (debounced 250 ms — ~12 Opus
frames — so packet jitter doesn't make the ring flicker).

ViewModel:
- `AudioRoomViewModel.uiState` gains `speakingNow`.
- New `onSpeakerActivity(pubkey)` is invoked once per received MoQ object
  via a `Flow.onEach` tap on `SubscribeHandle.objects` (before the
  AudioRoomPlayer consumes it). Each invocation (re)arms a per-speaker
  expiry coroutine that clears the entry after `SPEAKING_TIMEOUT_MS`.
- speakingNow is cleared whenever the underlying subscription closes
  (speaker removed from room) or the listener tears down (disconnect /
  onCleared).

Screen:
- AudioRoomViewModel is now hoisted up to AudioRoomStageContent so the
  speaker rows can read speakingNow alongside the connection UI.
- StagePeopleRow wraps each ClickableUserPicture in a 2 dp circular
  border when the participant is in `speakingNow`.
- updateSpeakers now receives `(hosts + speakers).pubKeys` so dynamic
  speaker promotion via NIP-53 event updates flows through to the
  reconcile loop already in M1.

Test:
- Adds `speakingNowClearsOnTeardown` to AudioRoomViewModelTest. The
  per-frame activity path is exercised end-to-end by the existing
  AudioRoomPlayerTest in :nestsClient (which uses the in-memory pipe).

Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:amethyst:compilePlayDebugKotlin` green.
This commit is contained in:
Claude
2026-04-26 03:02:34 +00:00
parent f9aa762d9b
commit 46f693800d
3 changed files with 146 additions and 53 deletions
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -27,6 +28,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
@@ -73,6 +75,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEv
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@@ -124,6 +128,8 @@ private fun AudioRoomStageContent(
!it.role.equals(ROLE.SPEAKER.code, true)
}
}
val onStage = remember(hosts, speakers) { hosts + speakers }
val onStageKeys = remember(onStage) { onStage.map { it.pubKey }.toSet() }
var handRaised by rememberSaveable(event.address().toValue()) { mutableStateOf(false) }
val scope = rememberCoroutineScope()
@@ -148,6 +154,36 @@ private fun AudioRoomStageContent(
}
}
val serviceBase = event.service()
val roomId = event.address().dTag
val audioAvailable = !serviceBase.isNullOrBlank() && roomId.isNotBlank()
val viewModel: AudioRoomViewModel? =
if (audioAvailable) {
val signer = account.signer
val viewModelKey = remember(serviceBase, roomId) { "$serviceBase|$roomId" }
viewModel(
key = viewModelKey,
factory =
remember(viewModelKey, signer) {
AudioRoomViewModelFactory(
signer = signer,
serviceBase = serviceBase,
roomId = roomId,
)
},
)
} else {
null
}
LaunchedEffect(viewModel, onStageKeys) {
viewModel?.updateSpeakers(onStageKeys)
}
val ui = viewModel?.uiState?.collectAsState()?.value
val speakingNow = ui?.speakingNow ?: persistentSetOf()
Card(
modifier = Modifier.fillMaxWidth().padding(8.dp),
shape = RoundedCornerShape(12.dp),
@@ -168,11 +204,12 @@ private fun AudioRoomStageContent(
)
}
if (hosts.isNotEmpty() || speakers.isNotEmpty()) {
if (onStage.isNotEmpty()) {
StagePeopleRow(
label = stringRes(R.string.audio_room_stage),
people = hosts + speakers,
people = onStage,
avatarSize = Size40dp,
speakingNow = speakingNow,
accountViewModel = accountViewModel,
)
}
@@ -182,15 +219,21 @@ private fun AudioRoomStageContent(
label = stringRes(R.string.audio_room_audience),
people = audience,
avatarSize = Size35dp,
speakingNow = persistentSetOf(),
accountViewModel = accountViewModel,
)
}
AudioConnectionRow(
event = event,
hosts = hosts,
accountViewModel = accountViewModel,
if (viewModel != null && ui != null) {
AudioConnectionRow(viewModel = viewModel, ui = ui)
} else {
Text(
text = stringRes(R.string.audio_room_audio_unavailable),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
@@ -219,8 +262,10 @@ private fun StagePeopleRow(
label: String,
people: List<ParticipantTag>,
avatarSize: androidx.compose.ui.unit.Dp,
speakingNow: ImmutableSet<String>,
accountViewModel: AccountViewModel,
) {
val ringColor = MaterialTheme.colorScheme.primary
Column(modifier = Modifier.padding(top = 8.dp)) {
Text(
text = label,
@@ -231,11 +276,19 @@ private fun StagePeopleRow(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
items(items = people, key = { it.pubKey }) {
items(items = people, key = { it.pubKey }) { participant ->
val isSpeaking = participant.pubKey in speakingNow
val avatarModifier =
if (isSpeaking) {
Modifier.border(2.dp, ringColor, CircleShape)
} else {
Modifier
}
ClickableUserPicture(
baseUserHex = it.pubKey,
baseUserHex = participant.pubKey,
size = avatarSize,
accountViewModel = accountViewModel,
modifier = avatarModifier,
)
}
}
@@ -243,52 +296,15 @@ private fun StagePeopleRow(
}
/**
* Connect / state-chip / mute row. Wires [AudioRoomViewModel] for the listener
* audio pipeline. Hidden when the room event has no `service` tag (legacy
* recordings or rooms hosted on non-nests servers — nothing to connect to).
* Connect / state-chip / mute row. Pure-render — the [AudioRoomViewModel] is
* owned by the parent so the speaker rows can read `speakingNow` from the
* same UI state.
*/
@Composable
private fun AudioConnectionRow(
event: MeetingSpaceEvent,
hosts: List<ParticipantTag>,
accountViewModel: AccountViewModel,
viewModel: AudioRoomViewModel,
ui: com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomUiState,
) {
val serviceBase = event.service()
val roomId = event.address().dTag
if (serviceBase.isNullOrBlank() || roomId.isBlank()) {
Text(
text = stringRes(R.string.audio_room_audio_unavailable),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
return
}
val signer = accountViewModel.account.signer
val viewModelKey = remember(serviceBase, roomId) { "$serviceBase|$roomId" }
val viewModel: AudioRoomViewModel =
viewModel(
key = viewModelKey,
factory =
remember(viewModelKey, signer) {
AudioRoomViewModelFactory(
signer = signer,
serviceBase = serviceBase,
roomId = roomId,
)
},
)
val hostKeys = remember(hosts) { hosts.map { it.pubKey }.toSet() }
LaunchedEffect(viewModel, hostKeys) {
viewModel.updateSpeakers(hostKeys)
}
val ui by viewModel.uiState.collectAsState()
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -39,9 +39,11 @@ import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@@ -90,6 +92,7 @@ class AudioRoomViewModel(
private var stateObserverJob: Job? = null
private val activeSubscriptions = mutableMapOf<String, ActiveSubscription>()
private val speakingExpiryJobs = mutableMapOf<String, Job>()
private var requestedSpeakers: Set<String> = emptySet()
private var closed = false
@@ -204,6 +207,10 @@ class AudioRoomViewModel(
*/
private fun closeSubscription(slot: ActiveSubscription) {
val handle = slot.detach()
speakingExpiryJobs.remove(slot.pubkey)?.cancel()
if (_uiState.value.speakingNow.contains(slot.pubkey)) {
_uiState.update { it.copy(speakingNow = (it.speakingNow - slot.pubkey).toPersistentSet()) }
}
if (handle != null) {
viewModelScope.launch { runCatching { handle.unsubscribe() } }
}
@@ -224,7 +231,10 @@ class AudioRoomViewModel(
// Apply current mute state before play() opens the device so the
// first frame respects it.
player.setMutedSafe(isMuted)
roomPlayer.play(handle.objects, onError = { /* swallow per-packet decoder errors */ })
// Tap the object flow to drive the speaking-now indicator before
// the decoder consumes it.
val instrumented = handle.objects.onEach { onSpeakerActivity(pubkey) }
roomPlayer.play(instrumented, onError = { /* swallow per-packet decoder errors */ })
slot.attach(handle, roomPlayer, player)
publishActiveSpeakers()
} catch (ce: CancellationException) {
@@ -247,6 +257,8 @@ class AudioRoomViewModel(
// the listener.close() below tears down the MoQ session.
activeSubscriptions.values.forEach { it.detach() }
activeSubscriptions.clear()
speakingExpiryJobs.values.forEach { it.cancel() }
speakingExpiryJobs.clear()
val l = listener
listener = null
if (l != null) {
@@ -255,7 +267,13 @@ class AudioRoomViewModel(
// underlying transport's own cleanup runs.
viewModelScope.launch { runCatching { l.close() } }
}
_uiState.update { it.copy(connection = targetState, activeSpeakers = persistentSetOf()) }
_uiState.update {
it.copy(
connection = targetState,
activeSpeakers = persistentSetOf(),
speakingNow = persistentSetOf(),
)
}
}
private fun publishActiveSpeakers() {
@@ -267,6 +285,31 @@ class AudioRoomViewModel(
_uiState.update { it.copy(activeSpeakers = active) }
}
/**
* Mark [pubkey] as currently speaking and (re)arm a [SPEAKING_TIMEOUT_MS]
* coroutine that clears it once they go quiet. Called once per
* MoQ object received on the speaker's track.
*/
private fun onSpeakerActivity(pubkey: String) {
if (closed) return
speakingExpiryJobs[pubkey]?.cancel()
if (!_uiState.value.speakingNow.contains(pubkey)) {
_uiState.update { it.copy(speakingNow = (it.speakingNow + pubkey).toPersistentSet()) }
}
speakingExpiryJobs[pubkey] =
viewModelScope.launch {
delay(SPEAKING_TIMEOUT_MS)
clearSpeaking(pubkey)
}
}
private fun clearSpeaking(pubkey: String) {
speakingExpiryJobs.remove(pubkey)
if (_uiState.value.speakingNow.contains(pubkey)) {
_uiState.update { it.copy(speakingNow = (it.speakingNow - pubkey).toPersistentSet()) }
}
}
private class ActiveSubscription private constructor(
val pubkey: String,
) {
@@ -362,9 +405,19 @@ private fun AudioPlayer.setMutedSafe(muted: Boolean) {
data class AudioRoomUiState(
val connection: ConnectionUiState = ConnectionUiState.Idle,
val isMuted: Boolean = false,
/** Pubkeys we have an open MoQ subscription for. */
val activeSpeakers: ImmutableSet<String> = persistentSetOf(),
/** Pubkeys whose audio track delivered an object in the last [SPEAKING_TIMEOUT_MS]. */
val speakingNow: ImmutableSet<String> = persistentSetOf(),
)
/**
* How long a speaker stays "speaking" after their last received MoQ object.
* Roughly 12 × the 20 ms Opus frame so brief packet jitter doesn't make the
* indicator flicker.
*/
const val SPEAKING_TIMEOUT_MS: Long = 250L
/**
* Indirection over the top-level `connectNestsListener` so tests can drive
* a fake [NestsListener] directly without standing up an HTTP fake +
@@ -187,6 +187,30 @@ class AudioRoomViewModelTest {
assertEquals(ConnectionUiState.Step.OpeningTransport, ui.step)
}
@Test
fun speakingNowClearsOnTeardown() =
runTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
vm.connect()
fakeListener.emit(NestsListenerState.Connected(ROOM_INFO, 0xff000011))
// Speaking-now is empty until an object arrives — exercising the
// timeout-based clearing requires a live SubscribeHandle, which is
// covered in nestsClient's pipe tests. Here we just verify the
// teardown contract: speakingNow returns to empty after disconnect.
vm.disconnect()
assertTrue(
vm.uiState.value.speakingNow
.isEmpty(),
)
assertTrue(
vm.uiState.value.activeSpeakers
.isEmpty(),
)
}
private fun newViewModel(connect: suspend (CoroutineScope) -> NestsListener): AudioRoomViewModel =
AudioRoomViewModel(
httpClient = NoopNestsClient,