feat(audio-rooms): emit publishing + onstage tags on presence heartbeat
Threads the new kind-10312 presence dimensions through the VM and the
heartbeat:
* AudioRoomUiState.onStageNow: explicit Boolean field, defaults to
true. Tier-2's "leave the stage" tap will flip it to false via
AudioRoomViewModel.setOnStage(...). Drives the
`["onstage", "0|1"]` tag.
* AudioRoomUiState.publishingNow: derived property, true only when
broadcast is Broadcasting AND not muted. Drives the
`["publishing", "0|1"]` tag. Matches the wire-tag semantics
"actually pushing audio packets" (vs holding a slot but silenced).
The heartbeat in AudioRoomActivityContent now reads both values and
passes them to MeetingRoomPresenceEvent.build. onstageTag IS a
LaunchedEffect key (so leaving the stage triggers an immediate
publish), publishingTag is NOT (mute toggles already publish via the
debounced effect).
The leave / dispose path now publishes publishing=false + onstage=false
explicitly, so aggregating peers can drop us from the grid immediately
instead of waiting out the staleness window.
Tests:
* AudioRoomViewModelTest::onStageNowDefaultsTrueAndSetOnStageFlipsIt
* AudioRoomViewModelTest::publishingNowDerivesFromBroadcastStateAndMute
(covers all four BroadcastUiState states + the muted-broadcasting
edge case where publishingNow must be false despite holding a slot)
This commit is contained in:
+30
-12
@@ -163,17 +163,20 @@ private fun AudioRoomActivityBody(
|
||||
is BroadcastUiState.Broadcasting -> b.isMuted
|
||||
else -> null
|
||||
}
|
||||
// Heartbeat loop — publishes once on enter / hand-raise change, then
|
||||
// every 30 s. micMutedTag is intentionally NOT a key here: every mute
|
||||
// toggle would otherwise trigger a presence publish + relay round trip
|
||||
// (audit Android #11). The next heartbeat picks up the latest mic
|
||||
// state up to 30 s later, which is well within the user's "did the
|
||||
// peer see my mute" tolerance.
|
||||
LaunchedEffect(event.address().toValue(), handRaised) {
|
||||
publishPresence(account, event, handRaised, micMutedTag)
|
||||
val publishingTag: Boolean = ui.publishingNow
|
||||
val onstageTag: Boolean = ui.onStageNow
|
||||
// Heartbeat loop — publishes once on enter / hand-raise / onstage
|
||||
// change, then every 30 s. micMutedTag and publishingTag are
|
||||
// intentionally NOT keys here: every mute toggle would otherwise
|
||||
// trigger a presence publish + relay round trip (audit Android #11).
|
||||
// The next heartbeat picks up the latest mic / publishing state up
|
||||
// to 30 s later, which is well within the user's "did the peer see
|
||||
// my mute" tolerance.
|
||||
LaunchedEffect(event.address().toValue(), handRaised, onstageTag) {
|
||||
publishPresence(account, event, handRaised, micMutedTag, publishingTag, onstageTag)
|
||||
while (isActive) {
|
||||
delay(PRESENCE_REFRESH_MS)
|
||||
publishPresence(account, event, handRaised, micMutedTag)
|
||||
publishPresence(account, event, handRaised, micMutedTag, publishingTag, onstageTag)
|
||||
}
|
||||
}
|
||||
// Debounced state-change publisher: after a mute toggle, wait
|
||||
@@ -188,7 +191,7 @@ private fun AudioRoomActivityBody(
|
||||
if (micMutedTag != null) {
|
||||
LaunchedEffect(micMutedTag) {
|
||||
delay(PRESENCE_DEBOUNCE_MS)
|
||||
publishPresence(account, event, handRaised, micMutedTag)
|
||||
publishPresence(account, event, handRaised, micMutedTag, publishingTag, onstageTag)
|
||||
}
|
||||
}
|
||||
DisposableEffect(event.address().toValue()) {
|
||||
@@ -196,10 +199,21 @@ private fun AudioRoomActivityBody(
|
||||
// Final "leaving" presence runs on a non-cancellable scope so
|
||||
// it survives the composable's scope being cancelled mid-
|
||||
// network. Without this the leave event almost never reaches
|
||||
// the relay (audit Android #12).
|
||||
// the relay (audit Android #12). publishing=false / onstage=false
|
||||
// tells aggregating peers to drop us from the participant grid
|
||||
// immediately rather than wait out the staleness window.
|
||||
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
|
||||
kotlinx.coroutines.GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching { publishPresence(account, event, handRaised = false, micMuted = null) }
|
||||
runCatching {
|
||||
publishPresence(
|
||||
account = account,
|
||||
event = event,
|
||||
handRaised = false,
|
||||
micMuted = null,
|
||||
publishing = false,
|
||||
onstage = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,6 +262,8 @@ private suspend fun publishPresence(
|
||||
event: MeetingSpaceEvent,
|
||||
handRaised: Boolean,
|
||||
micMuted: Boolean?,
|
||||
publishing: Boolean? = null,
|
||||
onstage: Boolean? = null,
|
||||
) {
|
||||
runCatching {
|
||||
account.signAndComputeBroadcast(
|
||||
@@ -255,6 +271,8 @@ private suspend fun publishPresence(
|
||||
root = event,
|
||||
handRaised = handRaised,
|
||||
muted = micMuted,
|
||||
publishing = publishing,
|
||||
onstage = onstage,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+29
-1
@@ -201,6 +201,18 @@ class AudioRoomViewModel(
|
||||
activeSubscriptions.values.forEach { it.player?.setMutedSafe(muted) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle whether we hold a speaker slot. Tier-2's "leave the stage"
|
||||
* tap flips this to `false`; the next kind-10312 heartbeat picks up
|
||||
* the change via [AudioRoomUiState.onStageNow]. Does NOT stop a
|
||||
* running broadcast — UI / caller is expected to call
|
||||
* [BroadcastHandle.close] separately when leaving the stage.
|
||||
*/
|
||||
fun setOnStage(onStage: Boolean) {
|
||||
if (closed) return
|
||||
_uiState.update { it.copy(onStageNow = onStage) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this VM was constructed with capture + encoder factories. The
|
||||
* UI uses this to decide whether to render the talk button at all —
|
||||
@@ -792,7 +804,23 @@ data class AudioRoomUiState(
|
||||
val speakingNow: ImmutableSet<String> = persistentSetOf(),
|
||||
/** Speaker / publisher state — only relevant when [AudioRoomViewModel.canBroadcast]. */
|
||||
val broadcast: BroadcastUiState = BroadcastUiState.Idle,
|
||||
)
|
||||
/**
|
||||
* `true` when we hold a speaker slot vs being pure audience. Defaults
|
||||
* to `true` for users with [AudioRoomViewModel.canBroadcast]; the
|
||||
* "leave the stage" tap (Tier 2) flips it to `false`. Drives the
|
||||
* `["onstage", "0|1"]` tag on emitted kind-10312 presence events.
|
||||
*/
|
||||
val onStageNow: Boolean = true,
|
||||
) {
|
||||
/**
|
||||
* Derived: are we currently pushing audio packets to the relay?
|
||||
* `true` only when the broadcast handle is live AND we're not muted.
|
||||
* Drives the `["publishing", "0|1"]` tag on emitted kind-10312
|
||||
* presence events.
|
||||
*/
|
||||
val publishingNow: Boolean
|
||||
get() = broadcast is BroadcastUiState.Broadcasting && !broadcast.isMuted
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed class BroadcastUiState {
|
||||
|
||||
+35
@@ -136,6 +136,41 @@ class AudioRoomViewModelTest {
|
||||
assertFalse(vm.uiState.value.isMuted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onStageNowDefaultsTrueAndSetOnStageFlipsIt() =
|
||||
runTest {
|
||||
val vm = newViewModel { FakeNestsListener() }
|
||||
|
||||
// Defaults to true so a freshly-joined speaker advertises
|
||||
// onstage=1 on the first heartbeat (the heartbeat in
|
||||
// AudioRoomActivityContent reads this as the tag value).
|
||||
assertTrue(vm.uiState.value.onStageNow)
|
||||
vm.setOnStage(false)
|
||||
assertFalse(vm.uiState.value.onStageNow)
|
||||
vm.setOnStage(true)
|
||||
assertTrue(vm.uiState.value.onStageNow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishingNowDerivesFromBroadcastStateAndMute() {
|
||||
// Idle / connecting / failed: never publishing.
|
||||
assertFalse(AudioRoomUiState().publishingNow)
|
||||
assertFalse(AudioRoomUiState(broadcast = BroadcastUiState.Connecting).publishingNow)
|
||||
assertFalse(AudioRoomUiState(broadcast = BroadcastUiState.Failed("boom")).publishingNow)
|
||||
|
||||
// Broadcasting unmuted: publishing.
|
||||
assertTrue(
|
||||
AudioRoomUiState(broadcast = BroadcastUiState.Broadcasting(isMuted = false)).publishingNow,
|
||||
)
|
||||
|
||||
// Broadcasting muted: NOT publishing — matches the wire-tag
|
||||
// semantics ("publishing=1" means actually pushing packets,
|
||||
// not "hold a slot but silenced").
|
||||
assertFalse(
|
||||
AudioRoomUiState(broadcast = BroadcastUiState.Broadcasting(isMuted = true)).publishingNow,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectIsIdempotentWhileConnecting() =
|
||||
runTest {
|
||||
|
||||
Reference in New Issue
Block a user