feat(audio-rooms): announce-driven speaker discovery (T4 #17b)

Surface moq-lite's ANNOUNCE flow on NestsListener so the audio
room can render an authoritative "actively broadcasting"
indicator independent of kind-10312 presence's `publishing`
flag. Same channel nostrnests' web client uses for its live
badges (`useRoomAnnouncements` in the JS reference).

Wire-up:
  * RoomAnnouncement(pubkey, active) data class — one update
    per publisher transition (Active → broadcast came up,
    inactive → broadcast went down).
  * NestsListener.announces(): Flow<RoomAnnouncement> with a
    default body that throws UnsupportedOperationException on
    the IETF reference path.
  * MoqLiteNestsListener implements via session.announce("")
    against the room's namespace; the suffix carries the
    speaker pubkey hex straight through.
  * ReconnectingNestsListener routes via collectLatest so the
    consumer-facing flow restarts against each new session
    after a refresh / reconnect (no SubscribeHandle re-issuance
    pump needed — announces is a cold per-collect stream).
  * AudioRoomViewModel.announcedSpeakers: StateFlow<Set<String>>
    populated by observeAnnounces. Active emissions add the
    pubkey, inactive emissions remove it. Cleared on teardown.
    IETF listeners leave the set empty; UI falls back to
    presence's publishingNow flag.

Tests:
  * NestsListenerCatalogTest — adds the announces() default-
    throws-on-collect case.

Closes the audit's Tier-4 #17b gap. UI integration (e.g. a green
"live" dot driven by announcedSpeakers) is a follow-up — the data
flow is in place and downstream consumers can opt in.
This commit is contained in:
Claude
2026-04-27 02:03:40 +00:00
parent efcd32f987
commit 062944de83
5 changed files with 161 additions and 0 deletions
@@ -171,6 +171,24 @@ class AudioRoomViewModel(
private val _speakerCatalogs = MutableStateFlow<Map<String, RoomSpeakerCatalog>>(emptyMap())
val speakerCatalogs: StateFlow<Map<String, RoomSpeakerCatalog>> = _speakerCatalogs.asStateFlow()
/**
* Pubkeys the moq-lite relay has announced as actively
* broadcasting in this room. Populated by [observeAnnounces]
* once the listener is Connected; an Active announce adds the
* pubkey, an Ended announce removes it. Independent of the
* kind-10312 `publishing` flag — announces come straight from
* the relay's view of who has an open broadcast track, while
* presence is the speaker's self-reported state. The two
* usually agree but the announce flow is the more authoritative
* "is this broadcast really live" signal.
*
* Empty when the listener doesn't expose announces (IETF
* reference path); the UI falls back to `publishingNow` from
* presence in that case.
*/
private val _announcedSpeakers = MutableStateFlow<Set<String>>(emptySet())
val announcedSpeakers: StateFlow<Set<String>> = _announcedSpeakers.asStateFlow()
/**
* `true` once the local user has been kicked (#5) — the platform
* layer flips this on a valid kind-4312 from a host/moderator and
@@ -198,6 +216,7 @@ class AudioRoomViewModel(
private var listener: NestsListener? = null
private var connectJob: Job? = null
private var stateObserverJob: Job? = null
private var announcesJob: Job? = null
// Last in-flight `listener.close()` launched by teardown(). A subsequent
// connect() awaits this before opening a fresh transport so two QUIC
@@ -505,6 +524,31 @@ class AudioRoomViewModel(
_uiState.update { it.copy(broadcast = targetState) }
}
/**
* Drain the moq-lite ANNOUNCE flow into [announcedSpeakers].
* Each Active emission adds the speaker pubkey; an inactive
* emission removes it. Best-effort — listeners that don't
* support announces (IETF reference path) throw on the first
* `collect` and we silently leave the set empty (the UI falls
* back to presence's `publishingNow` flag).
*/
private fun observeAnnounces(l: NestsListener) {
announcesJob?.cancel()
announcesJob =
viewModelScope.launch {
runCatching {
l.announces().collect { ann ->
if (closed) return@collect
if (ann.active) {
_announcedSpeakers.update { it + ann.pubkey }
} else {
_announcedSpeakers.update { it - ann.pubkey }
}
}
}
}
}
private fun observeListenerState(l: NestsListener) {
stateObserverJob?.cancel()
stateObserverJob =
@@ -583,6 +627,7 @@ class AudioRoomViewModel(
}
listener = l
observeListenerState(l)
observeAnnounces(l)
} catch (ce: CancellationException) {
throw ce
} catch (t: Throwable) {
@@ -746,6 +791,11 @@ class AudioRoomViewModel(
connectJob = null
stateObserverJob?.cancel()
stateObserverJob = null
announcesJob?.cancel()
announcesJob = null
if (_announcedSpeakers.value.isNotEmpty()) {
_announcedSpeakers.value = emptySet()
}
// Detach + suspend-stop each player on the cleanup scope. The
// listener.close() below tears down the MoQ session and drops
// every active subscription, so we don't need to call