feat(audio-rooms): consume moq-lite catalog metadata in VM

The catalog subscription API shipped two commits ago but nothing
read it. This wires it up:

  * RoomSpeakerCatalog data class (commons) — kotlinx.serialization
    parser for moq-lite's `catalog.json` payload (version, audio
    track list with codec / sample_rate / channel_count / bitrate).
    Forward-compat: ignoreUnknownKeys + nullable fields tolerate
    new keys and partial publishers.
  * AudioRoomViewModel.speakerCatalogs: StateFlow<Map<String,
    RoomSpeakerCatalog>> populated lazily as per-speaker
    subscriptions land. Catalog fetch piggybacks on openSubscription
    via subscribeCatalog — best-effort, doesn't gate audio.
  * Participant context sheet renders a single-line summary
    ("OPUS · 48kHz · 2ch") below the pubkey when the catalog is
    available.
  * Enabled kotlinx.serialization plugin on :commons (was already
    a transitive dep, just not wired for @Serializable codegen).

Tests:
  * RoomSpeakerCatalogTest — 7 cases covering the canonical shape,
    describe() formatting (codec uppercased, kHz / channel
    short-formed), all-null fallback, unknown-key tolerance,
    garbage-bytes fallback, and empty-audio-list.
This commit is contained in:
Claude
2026-04-27 01:35:18 +00:00
parent 3fb9f02b6d
commit d9fe3b5f83
6 changed files with 265 additions and 0 deletions
@@ -159,6 +159,18 @@ class AudioRoomViewModel(
private val _recentReactions = MutableStateFlow<Map<String, List<RoomReaction>>>(emptyMap())
val recentReactions: StateFlow<Map<String, List<RoomReaction>>> = _recentReactions.asStateFlow()
/**
* moq-lite `catalog.json` payload per speaker pubkey. Populated
* lazily as the per-speaker subscriptions land — listeners
* without catalog support (IETF reference path) leave this map
* empty. The participant context sheet reads this to surface
* codec / sample-rate info; future commits could use it for
* "speaker is broadcasting" indicators independent of the
* actively-emitting `speakingNow` set.
*/
private val _speakerCatalogs = MutableStateFlow<Map<String, RoomSpeakerCatalog>>(emptyMap())
val speakerCatalogs: StateFlow<Map<String, RoomSpeakerCatalog>> = _speakerCatalogs.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
@@ -616,6 +628,9 @@ class AudioRoomViewModel(
if (_uiState.value.speakingNow.contains(slot.pubkey)) {
_uiState.update { it.copy(speakingNow = (it.speakingNow - slot.pubkey).toPersistentSet()) }
}
if (_speakerCatalogs.value.containsKey(slot.pubkey)) {
_speakerCatalogs.update { it - slot.pubkey }
}
if (roomPlayer != null || handle != null) {
viewModelScope.launch {
roomPlayer?.runCatching { stop() }
@@ -624,6 +639,32 @@ class AudioRoomViewModel(
}
}
/**
* Open the speaker's `catalog.json` track in the background, parse
* the first frame, and stash it in [speakerCatalogs]. Best-effort —
* any failure (IETF listener throws UnsupportedOperationException,
* the publisher doesn't publish a catalog, the JSON is malformed)
* is silent. The catalog channel uses the same re-issuing pump
* the audio path does, so it survives reconnects.
*/
private fun fetchSpeakerCatalog(
l: NestsListener,
pubkey: String,
) {
viewModelScope.launch {
val handle = runCatching { l.subscribeCatalog(pubkey) }.getOrNull() ?: return@launch
try {
handle.objects.collect { obj ->
if (closed) return@collect
val parsed = RoomSpeakerCatalog.parseOrNull(obj.payload) ?: return@collect
_speakerCatalogs.update { it + (pubkey to parsed) }
}
} finally {
runCatching { handle.unsubscribe() }
}
}
}
private suspend fun openSubscription(
l: NestsListener,
pubkey: String,
@@ -632,6 +673,9 @@ class AudioRoomViewModel(
if (closed || activeSubscriptions[pubkey] !== slot) return
try {
val handle = l.subscribeSpeaker(pubkey)
// Parallel catalog fetch — best-effort, doesn't gate audio
// playback. Cancelled implicitly when the VM scope is cancelled.
fetchSpeakerCatalog(l, pubkey)
// Re-check after the suspending subscribeSpeaker — the user
// may have removed this speaker via updateSpeakers / disconnected
// while the SUBSCRIBE was in flight. If so, abandon the handle
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.viewmodels
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Decoded shape of a moq-lite `catalog.json` track for a single
* audio-room speaker. Each broadcast advertises one or more tracks
* (one per codec / quality tier) — for nests audio rooms there's
* exactly one Opus track today, but the model carries a list for
* forward compatibility.
*
* Best-effort schema: nostrnests' upstream moq-lite catalog spec
* (kixelated/moq) has evolved across revisions, and not every
* publisher fills in every field. The parser tolerates missing
* keys via `ignoreUnknownKeys = true` on [JsonMapper] and the
* `?`-marked properties.
*/
@Immutable
@Serializable
data class RoomSpeakerCatalog(
val version: Int? = null,
val audio: List<AudioTrack> = emptyList(),
) {
@Immutable
@Serializable
data class AudioTrack(
val track: String? = null,
val codec: String? = null,
@SerialName("sample_rate") val sampleRate: Int? = null,
@SerialName("channel_count") val channelCount: Int? = null,
val bitrate: Int? = null,
) {
/**
* Short single-line summary suitable for a UI tooltip /
* subtitle. Returns null when there's nothing useful to
* show — the caller can omit the line entirely rather than
* render "unknown · unknown".
*/
fun describe(): String? {
val parts =
buildList {
codec?.takeIf { it.isNotBlank() }?.let { add(it.uppercase()) }
sampleRate?.let { add("${it / 1000}kHz") }
channelCount?.let { add(if (it == 1) "mono" else "${it}ch") }
}
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
}
}
/** First audio track, if any. The current single-Opus reality. */
fun primaryAudio(): AudioTrack? = audio.firstOrNull()
companion object {
/**
* Parse a UTF-8 JSON byte array delivered on the
* `catalog.json` track. Returns null when the bytes don't
* decode as JSON or don't match the expected shape — the
* caller falls back to "no catalog" rather than blowing up
* the UI.
*/
fun parseOrNull(bytes: ByteArray): RoomSpeakerCatalog? {
val text =
runCatching { bytes.decodeToString() }.getOrNull()
?: return null
return runCatching { JsonMapper.fromJson<RoomSpeakerCatalog>(text) }.getOrNull()
}
}
}