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:
+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 +
|
||||
|
||||
Reference in New Issue
Block a user