feat(audio-rooms): M1 listener-only wire-up — Connect button + Connected chip + mute

Wires `connectNestsListener` into the existing NIP-53 audio-room "stage" so
a user can tap Connect, see the live connection state, and hear hosts
speaking. Per the audio-rooms completion plan
(`nestsClient/plans/2026-04-26-audio-rooms-completion.md`) phase M1.

UI (amethyst):
- AudioRoomStage gets a state-chip / Connect / Disconnect / Mute row
  above the existing hand-raise. State chip walks ResolvingRoom →
  OpeningTransport → MoQ-handshake → Connected; Failed surfaces the
  reason inline with a Retry button. The 30312 `service` tag drives
  visibility — rooms hosted on non-nests servers show "Audio not
  available" and the rest of the stage UI continues to work.

ViewModel (commons):
- New `AudioRoomViewModel` in commons/.../viewmodels/ owns one
  `NestsListener` and one `AudioRoomPlayer` per active speaker, exposes
  a single `StateFlow<AudioRoomUiState>`, and reconciles subscriptions
  whenever the screen pushes a new speaker set via `updateSpeakers`.
  Audio-pipeline construction is injected via `decoderFactory` /
  `playerFactory` so a future desktop port can reuse the orchestration
  once Compose Desktop has WebTransport. Unit-tested with a fake
  `NestsListenerConnector` driving state transitions directly.

nestsClient:
- `AudioPlayer.setMuted(Boolean)` joins the interface; `AudioTrackPlayer`
  routes it through `AudioTrack.setVolume(0f/1f)`. Mute keeps the
  decode + network pipeline running so unmute is sample-accurate.

Build:
- `:commons` commonMain now `implementation(project(":nestsClient"))` so
  the shared VM can see `NestsListener` / `AudioRoomPlayer` / interfaces.
  Concrete OkHttp / Quic / MediaCodec / AudioTrack actuals stay in
  `:nestsClient`'s platform source sets and are bound by an
  Android-side `AudioRoomViewModelFactory` co-located with the screen.

Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:quic:jvmTest :amethyst:compilePlayDebugKotlin` all green.
This commit is contained in:
Claude
2026-04-26 02:54:43 +00:00
parent c24e676004
commit f9aa762d9b
8 changed files with 936 additions and 13 deletions
@@ -0,0 +1,417 @@
/*
* 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 androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.nestsclient.NestsClient
import com.vitorpamplona.nestsclient.NestsListener
import com.vitorpamplona.nestsclient.NestsListenerState
import com.vitorpamplona.nestsclient.audio.AudioPlayer
import com.vitorpamplona.nestsclient.audio.AudioRoomPlayer
import com.vitorpamplona.nestsclient.audio.OpusDecoder
import com.vitorpamplona.nestsclient.connectNestsListener
import com.vitorpamplona.nestsclient.moq.SubscribeHandle
import com.vitorpamplona.nestsclient.transport.WebTransportFactory
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Per-screen state holder for a NIP-53 audio-room.
*
* Owns one [NestsListener] for the lifetime of the screen and one
* [AudioRoomPlayer] per active speaker subscription. The screen tells the
* VM which speakers it cares about via [updateSpeakers]; subscribe / play
* happen automatically once the listener is [NestsListenerState.Connected].
*
* Lifecycle:
* - Construction: idle. Nothing is on the wire until [connect] is called.
* - [connect]: launches the HTTP→WebTransport→MoQ handshake. Idempotent
* while connecting/connected.
* - [setMuted]: routes through to every player so the mute toggle is
* instant; the network keeps running so unmute has no extra latency.
* - [disconnect] / [onCleared]: cancels subscriptions, stops players,
* closes the listener. Idempotent.
*
* Audio-pipeline construction is injected via [decoderFactory] /
* [playerFactory] so commonMain doesn't have to know which platform's
* MediaCodec / AudioTrack is in play. M1 wires Android-only — desktop
* passes nothing here yet.
*/
@Stable
class AudioRoomViewModel(
private val httpClient: NestsClient,
private val transport: WebTransportFactory,
private val decoderFactory: () -> OpusDecoder,
private val playerFactory: () -> AudioPlayer,
private val signer: NostrSigner,
private val serviceBase: String,
private val roomId: String,
// Seam for tests — production code uses the default which delegates to
// the real `connectNestsListener`. Tests inject a fake that returns a
// listener whose state they can drive directly.
private val connector: NestsListenerConnector = DefaultNestsListenerConnector,
) : ViewModel() {
private val _uiState = MutableStateFlow(AudioRoomUiState())
val uiState: StateFlow<AudioRoomUiState> = _uiState.asStateFlow()
private var listener: NestsListener? = null
private var connectJob: Job? = null
private var stateObserverJob: Job? = null
private val activeSubscriptions = mutableMapOf<String, ActiveSubscription>()
private var requestedSpeakers: Set<String> = emptySet()
private var closed = false
/** Push the latest known speaker set from the room event. */
fun updateSpeakers(speakerPubkeys: Set<String>) {
if (closed) return
requestedSpeakers = speakerPubkeys
if (_uiState.value.connection is ConnectionUiState.Connected) {
reconcileSubscriptions()
}
}
/**
* Kick off the HTTP → WebTransport → MoQ handshake. No-op if a connect
* attempt is already in flight or already connected.
*/
fun connect() {
if (closed) return
val current = _uiState.value.connection
if (current is ConnectionUiState.Connecting || current is ConnectionUiState.Connected) return
_uiState.update { it.copy(connection = ConnectionUiState.Connecting(ConnectionUiState.Step.ResolvingRoom)) }
connectJob =
viewModelScope.launch {
try {
val l =
connector.connect(
httpClient = httpClient,
transport = transport,
scope = viewModelScope,
serviceBase = serviceBase,
roomId = roomId,
signer = signer,
)
if (closed) {
runCatching { l.close() }
return@launch
}
listener = l
observeListenerState(l)
} catch (ce: CancellationException) {
throw ce
} catch (t: Throwable) {
_uiState.update {
it.copy(connection = ConnectionUiState.Failed(t.message ?: t::class.simpleName ?: "connect failed"))
}
}
}
}
fun setMuted(muted: Boolean) {
if (closed) return
_uiState.update { it.copy(isMuted = muted) }
activeSubscriptions.values.forEach { it.player?.setMutedSafe(muted) }
}
/** Tear down without finalizing the VM (e.g. user pressed Disconnect). */
fun disconnect() {
if (closed) return
teardown(targetState = ConnectionUiState.Idle)
}
override fun onCleared() {
closed = true
teardown(targetState = ConnectionUiState.Closed)
super.onCleared()
}
private fun observeListenerState(l: NestsListener) {
stateObserverJob?.cancel()
stateObserverJob =
viewModelScope.launch {
l.state.collect { state ->
_uiState.update { ui ->
ui.copy(connection = state.toUiState(ui.connection))
}
if (state is NestsListenerState.Connected) {
reconcileSubscriptions()
}
}
}
}
private fun reconcileSubscriptions() {
val l = listener ?: return
if (_uiState.value.connection !is ConnectionUiState.Connected) return
val toAdd = requestedSpeakers - activeSubscriptions.keys
val toRemove = activeSubscriptions.keys - requestedSpeakers
toRemove.forEach { pubkey ->
activeSubscriptions.remove(pubkey)?.let { closeSubscription(it) }
}
toAdd.forEach { pubkey ->
// Mark as pending immediately so concurrent reconciles don't
// double-subscribe; flip to active once the SUBSCRIBE_OK arrives.
val pending = ActiveSubscription.pending(pubkey)
activeSubscriptions[pubkey] = pending
viewModelScope.launch {
openSubscription(l, pubkey, pending)
}
}
publishActiveSpeakers()
}
/**
* Stop the per-speaker player synchronously (releases the audio device
* immediately) and fire-and-forget the MoQ UNSUBSCRIBE on the VM scope.
*/
private fun closeSubscription(slot: ActiveSubscription) {
val handle = slot.detach()
if (handle != null) {
viewModelScope.launch { runCatching { handle.unsubscribe() } }
}
}
private suspend fun openSubscription(
l: NestsListener,
pubkey: String,
slot: ActiveSubscription,
) {
if (closed || activeSubscriptions[pubkey] !== slot) return
try {
val handle = l.subscribeSpeaker(pubkey)
val decoder = decoderFactory()
val player = playerFactory()
val isMuted = _uiState.value.isMuted
val roomPlayer = AudioRoomPlayer(decoder, player, viewModelScope)
// Apply current mute state before play() opens the device so the
// first frame respects it.
player.setMutedSafe(isMuted)
roomPlayer.play(handle.objects, onError = { /* swallow per-packet decoder errors */ })
slot.attach(handle, roomPlayer, player)
publishActiveSpeakers()
} catch (ce: CancellationException) {
throw ce
} catch (t: Throwable) {
// Roll back the slot we reserved so a future reconcile can retry.
if (activeSubscriptions[pubkey] === slot) {
activeSubscriptions.remove(pubkey)
publishActiveSpeakers()
}
}
}
private fun teardown(targetState: ConnectionUiState) {
connectJob?.cancel()
connectJob = null
stateObserverJob?.cancel()
stateObserverJob = null
// Stop players synchronously — unsubscribe happens implicitly when
// the listener.close() below tears down the MoQ session.
activeSubscriptions.values.forEach { it.detach() }
activeSubscriptions.clear()
val l = listener
listener = null
if (l != null) {
// Closing the listener is suspending; fire-and-forget on the VM
// scope is fine — even if the scope is cancelled (onCleared), the
// underlying transport's own cleanup runs.
viewModelScope.launch { runCatching { l.close() } }
}
_uiState.update { it.copy(connection = targetState, activeSpeakers = persistentSetOf()) }
}
private fun publishActiveSpeakers() {
val active =
activeSubscriptions
.filterValues { it.isPlaying }
.keys
.toPersistentSet()
_uiState.update { it.copy(activeSpeakers = active) }
}
private class ActiveSubscription private constructor(
val pubkey: String,
) {
private var handle: SubscribeHandle? = null
private var roomPlayer: AudioRoomPlayer? = null
var player: AudioPlayer? = null
private set
var isPlaying: Boolean = false
private set
fun attach(
handle: SubscribeHandle,
roomPlayer: AudioRoomPlayer,
player: AudioPlayer,
) {
this.handle = handle
this.roomPlayer = roomPlayer
this.player = player
this.isPlaying = true
}
/**
* Stop the player + decoder synchronously and return the
* [SubscribeHandle] (if any) so the caller can fire-and-forget
* UNSUBSCRIBE on its own coroutine scope.
*/
fun detach(): SubscribeHandle? {
isPlaying = false
roomPlayer?.let { runCatching { it.stop() } }
val h = handle
roomPlayer = null
handle = null
player = null
return h
}
companion object {
fun pending(pubkey: String) = ActiveSubscription(pubkey)
}
}
// Platform-specific Factory lives in `amethyst/.../audiorooms/room/`,
// not commonMain — the lifecycle KMP `ViewModelProvider.Factory`
// signature has been migrating between releases; a thin Android-side
// factory keeps that churn out of shared code.
}
/**
* Map [NestsListenerState] (from the transport library) onto the small set of
* UI-level states the screen actually needs to render. We collapse the three
* `Connecting` substeps into one enum so the chip can show a single
* progress message; advanced UI can branch on [ConnectionUiState.Step]
* later if it cares.
*/
private fun NestsListenerState.toUiState(previous: ConnectionUiState): ConnectionUiState =
when (this) {
// The transport library starts in Idle, but the VM has already
// shown "Connecting → ResolvingRoom" by the time observation starts;
// don't regress the UI back to Idle on the very first emission.
NestsListenerState.Idle -> {
if (previous is ConnectionUiState.Connecting) previous else ConnectionUiState.Idle
}
is NestsListenerState.Connecting -> {
ConnectionUiState.Connecting(
step =
when (step) {
NestsListenerState.Connecting.ConnectStep.ResolvingRoom -> ConnectionUiState.Step.ResolvingRoom
NestsListenerState.Connecting.ConnectStep.OpeningTransport -> ConnectionUiState.Step.OpeningTransport
NestsListenerState.Connecting.ConnectStep.MoqHandshake -> ConnectionUiState.Step.MoqHandshake
},
)
}
is NestsListenerState.Connected -> {
ConnectionUiState.Connected
}
is NestsListenerState.Failed -> {
ConnectionUiState.Failed(reason)
}
NestsListenerState.Closed -> {
ConnectionUiState.Closed
}
}
private fun AudioPlayer.setMutedSafe(muted: Boolean) {
runCatching { setMuted(muted) }
}
@Immutable
data class AudioRoomUiState(
val connection: ConnectionUiState = ConnectionUiState.Idle,
val isMuted: Boolean = false,
val activeSpeakers: ImmutableSet<String> = persistentSetOf(),
)
/**
* Indirection over the top-level `connectNestsListener` so tests can drive
* a fake [NestsListener] directly without standing up an HTTP fake +
* WebTransport fake.
*/
fun interface NestsListenerConnector {
suspend fun connect(
httpClient: NestsClient,
transport: WebTransportFactory,
scope: CoroutineScope,
serviceBase: String,
roomId: String,
signer: NostrSigner,
): NestsListener
}
private val DefaultNestsListenerConnector =
NestsListenerConnector { httpClient, transport, scope, serviceBase, roomId, signer ->
connectNestsListener(
httpClient = httpClient,
transport = transport,
scope = scope,
serviceBase = serviceBase,
roomId = roomId,
signer = signer,
)
}
@Immutable
sealed class ConnectionUiState {
data object Idle : ConnectionUiState()
data class Connecting(
val step: Step,
) : ConnectionUiState()
data object Connected : ConnectionUiState()
data class Failed(
val reason: String,
) : ConnectionUiState()
data object Closed : ConnectionUiState()
enum class Step {
ResolvingRoom,
OpeningTransport,
MoqHandshake,
}
}
@@ -0,0 +1,295 @@
/*
* 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 com.vitorpamplona.nestsclient.NestsClient
import com.vitorpamplona.nestsclient.NestsException
import com.vitorpamplona.nestsclient.NestsListener
import com.vitorpamplona.nestsclient.NestsListenerState
import com.vitorpamplona.nestsclient.NestsRoomInfo
import com.vitorpamplona.nestsclient.audio.AudioPlayer
import com.vitorpamplona.nestsclient.audio.OpusDecoder
import com.vitorpamplona.nestsclient.moq.SubscribeHandle
import com.vitorpamplona.nestsclient.transport.WebTransportFactory
import com.vitorpamplona.nestsclient.transport.WebTransportSession
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* Drives [AudioRoomViewModel] with a fake [NestsListenerConnector] and
* verifies its state-flow transitions:
* - connect() shows Connecting before the underlying connector returns
* - listener emits Connected → uiState collapses to Connected
* - listener emits Failed → uiState becomes Failed(reason)
* - setMuted updates uiState and propagates to active subscriptions
* - disconnect() returns to Idle and tears down the listener
*
* Subscribe-side (subscribeSpeaker → AudioRoomPlayer.play) is exercised via
* the existing nestsClient tests; the cross-module audio glue would need
* an internal SubscribeHandle constructor that's intentionally not visible
* here.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AudioRoomViewModelTest {
@BeforeTest
fun setupMainDispatcher() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}
@AfterTest
fun resetMainDispatcher() {
Dispatchers.resetMain()
}
@Test
fun connectShowsConnectingThenConnected() =
runTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
vm.connect()
// Initial UI shows the resolving substep before the connector
// (suspend) has a chance to actually return — that's the
// ResolvingRoom optimistic enter we set on connect().
assertIs<ConnectionUiState.Connecting>(vm.uiState.value.connection)
// Connector resolves with the listener; it's still Idle until we
// emit something. Drive it Connected directly.
fakeListener.emit(NestsListenerState.Connected(roomInfo = ROOM_INFO, negotiatedMoqVersion = 0xff000011))
assertEquals(ConnectionUiState.Connected, vm.uiState.value.connection)
}
@Test
fun listenerFailedSurfacesAsUiFailed() =
runTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
vm.connect()
fakeListener.emit(NestsListenerState.Failed("relay rejected", IllegalStateException("oops")))
val ui = vm.uiState.value.connection
assertIs<ConnectionUiState.Failed>(ui)
assertEquals("relay rejected", ui.reason)
}
@Test
fun connectorThrowsBecomesUiFailed() =
runTest {
val vm = newViewModel { throw NestsException("dns blew up") }
vm.connect()
val ui = vm.uiState.value.connection
assertIs<ConnectionUiState.Failed>(ui)
assertEquals("dns blew up", ui.reason)
}
@Test
fun setMutedFlipsUiStateAndIsRetained() =
runTest {
val vm = newViewModel { FakeNestsListener() }
assertFalse(vm.uiState.value.isMuted)
vm.setMuted(true)
assertTrue(vm.uiState.value.isMuted)
vm.setMuted(false)
assertFalse(vm.uiState.value.isMuted)
}
@Test
fun connectIsIdempotentWhileConnecting() =
runTest {
val fakeListener = FakeNestsListener()
var connectCalls = 0
val vm =
newViewModel {
connectCalls++
fakeListener
}
vm.connect()
vm.connect()
vm.connect()
assertEquals(1, connectCalls, "connect() should not re-fire while a Connecting flow is in flight")
}
@Test
fun disconnectReturnsToIdleAndClosesListener() =
runTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
vm.connect()
fakeListener.emit(NestsListenerState.Connected(ROOM_INFO, 0xff000011))
assertEquals(ConnectionUiState.Connected, vm.uiState.value.connection)
vm.disconnect()
assertEquals(ConnectionUiState.Idle, vm.uiState.value.connection)
assertTrue(fakeListener.closeCallCount > 0, "listener.close() should run on disconnect()")
}
@Test
fun connectingStepMapsThroughToUiStep() =
runTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
vm.connect()
fakeListener.emit(
NestsListenerState.Connecting(
NestsListenerState.Connecting.ConnectStep.OpeningTransport,
),
)
val ui = vm.uiState.value.connection
assertIs<ConnectionUiState.Connecting>(ui)
assertEquals(ConnectionUiState.Step.OpeningTransport, ui.step)
}
private fun newViewModel(connect: suspend (CoroutineScope) -> NestsListener): AudioRoomViewModel =
AudioRoomViewModel(
httpClient = NoopNestsClient,
transport = NoopWebTransportFactory,
decoderFactory = { NoopOpusDecoder },
playerFactory = { NoopAudioPlayer() },
signer = NoopSigner,
serviceBase = "https://example.test/api/v1/nests",
roomId = "test-room",
connector =
NestsListenerConnector { _, _, scope, _, _, _ ->
connect(scope)
},
)
private class FakeNestsListener : NestsListener {
private val mutable = MutableStateFlow<NestsListenerState>(NestsListenerState.Idle)
override val state: StateFlow<NestsListenerState> = mutable.asStateFlow()
var closeCallCount: Int = 0
private set
fun emit(s: NestsListenerState) {
mutable.value = s
}
override suspend fun subscribeSpeaker(speakerPubkeyHex: String): SubscribeHandle = error("subscribeSpeaker not exercised in these tests — see AudioRoomPlayerTest in :nestsClient")
override suspend fun close() {
closeCallCount++
mutable.value = NestsListenerState.Closed
}
}
private object NoopNestsClient : NestsClient {
override suspend fun resolveRoom(
serviceBase: String,
roomId: String,
signer: NostrSigner,
): NestsRoomInfo = error("resolveRoom not used (connector seam bypasses it)")
}
private object NoopWebTransportFactory : WebTransportFactory {
override suspend fun connect(
authority: String,
path: String,
bearerToken: String?,
): WebTransportSession = error("connect not used (connector seam bypasses it)")
}
private object NoopOpusDecoder : OpusDecoder {
override fun decode(opusPacket: ByteArray): ShortArray = ShortArray(0)
override fun release() {}
}
private class NoopAudioPlayer : AudioPlayer {
override fun start() {}
override suspend fun enqueue(pcm: ShortArray) {}
override fun setMuted(muted: Boolean) {}
override fun stop() {}
}
private object NoopSigner : NostrSigner(pubKey = "0".repeat(64)) {
override fun isWriteable(): Boolean = false
override fun hasForegroundSupport(): Boolean = false
override suspend fun <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
): T = error("sign not used in this VM test")
override suspend fun nip04Encrypt(
plaintext: String,
toPublicKey: String,
): String = error("not used")
override suspend fun nip04Decrypt(
ciphertext: String,
fromPublicKey: String,
): String = error("not used")
override suspend fun nip44Encrypt(
plaintext: String,
toPublicKey: String,
): String = error("not used")
override suspend fun nip44Decrypt(
ciphertext: String,
fromPublicKey: String,
): String = error("not used")
override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = error("not used")
override suspend fun deriveKey(nonce: String): String = error("not used")
}
companion object {
private val ROOM_INFO = NestsRoomInfo(endpoint = "https://relay.example.test/moq")
}
}