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
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,
),
)
}
@@ -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 {
@@ -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 {