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
|
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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-1
@@ -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 {
|
||||||
|
|||||||
+35
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user