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:
+44
@@ -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
|
||||
|
||||
+91
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user