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:
+66
-50
@@ -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,
|
||||
|
||||
+55
-2
@@ -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 +
|
||||
|
||||
+24
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user