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:
Claude
2026-04-26 21:39:49 +00:00
parent 5a5eaa3b50
commit 9c3cbdaee6
3 changed files with 94 additions and 13 deletions
@@ -163,17 +163,20 @@ private fun AudioRoomActivityBody(
is BroadcastUiState.Broadcasting -> b.isMuted is BroadcastUiState.Broadcasting -> b.isMuted
else -> null else -> null
} }
// Heartbeat loop — publishes once on enter / hand-raise change, then val publishingTag: Boolean = ui.publishingNow
// every 30 s. micMutedTag is intentionally NOT a key here: every mute val onstageTag: Boolean = ui.onStageNow
// toggle would otherwise trigger a presence publish + relay round trip // Heartbeat loop — publishes once on enter / hand-raise / onstage
// (audit Android #11). The next heartbeat picks up the latest mic // change, then every 30 s. micMutedTag and publishingTag are
// state up to 30 s later, which is well within the user's "did the // intentionally NOT keys here: every mute toggle would otherwise
// peer see my mute" tolerance. // trigger a presence publish + relay round trip (audit Android #11).
LaunchedEffect(event.address().toValue(), handRaised) { // The next heartbeat picks up the latest mic / publishing state up
publishPresence(account, event, handRaised, micMutedTag) // 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) { while (isActive) {
delay(PRESENCE_REFRESH_MS) 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 // Debounced state-change publisher: after a mute toggle, wait
@@ -188,7 +191,7 @@ private fun AudioRoomActivityBody(
if (micMutedTag != null) { if (micMutedTag != null) {
LaunchedEffect(micMutedTag) { LaunchedEffect(micMutedTag) {
delay(PRESENCE_DEBOUNCE_MS) delay(PRESENCE_DEBOUNCE_MS)
publishPresence(account, event, handRaised, micMutedTag) publishPresence(account, event, handRaised, micMutedTag, publishingTag, onstageTag)
} }
} }
DisposableEffect(event.address().toValue()) { DisposableEffect(event.address().toValue()) {
@@ -196,10 +199,21 @@ private fun AudioRoomActivityBody(
// Final "leaving" presence runs on a non-cancellable scope so // Final "leaving" presence runs on a non-cancellable scope so
// it survives the composable's scope being cancelled mid- // it survives the composable's scope being cancelled mid-
// network. Without this the leave event almost never reaches // 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) @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
kotlinx.coroutines.GlobalScope.launch(Dispatchers.IO) { 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, event: MeetingSpaceEvent,
handRaised: Boolean, handRaised: Boolean,
micMuted: Boolean?, micMuted: Boolean?,
publishing: Boolean? = null,
onstage: Boolean? = null,
) { ) {
runCatching { runCatching {
account.signAndComputeBroadcast( account.signAndComputeBroadcast(
@@ -255,6 +271,8 @@ private suspend fun publishPresence(
root = event, root = event,
handRaised = handRaised, handRaised = handRaised,
muted = micMuted, muted = micMuted,
publishing = publishing,
onstage = onstage,
), ),
) )
} }
@@ -201,6 +201,18 @@ class AudioRoomViewModel(
activeSubscriptions.values.forEach { it.player?.setMutedSafe(muted) } 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 * Whether this VM was constructed with capture + encoder factories. The
* UI uses this to decide whether to render the talk button at all — * 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(), val speakingNow: ImmutableSet<String> = persistentSetOf(),
/** Speaker / publisher state — only relevant when [AudioRoomViewModel.canBroadcast]. */ /** Speaker / publisher state — only relevant when [AudioRoomViewModel.canBroadcast]. */
val broadcast: BroadcastUiState = BroadcastUiState.Idle, 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 @Immutable
sealed class BroadcastUiState { sealed class BroadcastUiState {
@@ -136,6 +136,41 @@ class AudioRoomViewModelTest {
assertFalse(vm.uiState.value.isMuted) 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 @Test
fun connectIsIdempotentWhileConnecting() = fun connectIsIdempotentWhileConnecting() =
runTest { runTest {