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
@@ -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 {