diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomActivityContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomActivityContent.kt index f2bbe0eb2..30808d190 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomActivityContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomActivityContent.kt @@ -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, ), ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt index 225dcc893..eb75ea94c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt @@ -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 = 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 { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt index c74c0339a..8a219370b 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt @@ -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 {