diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt index 1136475a9..6e12c597b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt @@ -28,14 +28,19 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilledTonalIconToggleButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -45,16 +50,25 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomViewModel +import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.nestsclient.OkHttpNestsClient +import com.vitorpamplona.nestsclient.audio.AudioTrackPlayer +import com.vitorpamplona.nestsclient.audio.MediaCodecOpusDecoder +import com.vitorpamplona.nestsclient.transport.QuicWebTransportFactory import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag @@ -68,20 +82,20 @@ import kotlinx.coroutines.launch * Clubhouse-style audio-room "stage" rendered in place of the video player when * the underlying activity is a NIP-53 kind 30312 [MeetingSpaceEvent]. * - * Current (shipping) scope — pure Nostr, no audio transport: + * Responsibilities (M1 = listener-only): * - Displays host / speaker / audience avatars parsed from the 30312 `p` tags. * - Publishes kind 10312 presence on enter and every 30 s while composed. * - Hand-raise toggle flips the `["hand","1"|"0"]` tag on that presence event - * so a host on any NIP-53 client (browser, other Android, etc.) can see the - * request and promote the user to speaker. + * so a host on any NIP-53 client can see the request and promote. + * - **Connect button** opens the listener-side audio pipeline (HTTP → + * WebTransport over QUIC → MoQ → Opus decode → AudioTrack). On Connected, + * auto-subscribes to every host's speaker track. + * - **Mute toggle** silences the local audio device without halting the + * network pipeline so unmute is instant. * - * Audio playback / capture + the "Audio connected" chip + the mute button live - * behind the WebTransport + QUIC work tracked in - * `docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md`. They're not - * exposed in the UI until that transport actually runs — a chip that always - * reads "Failed: NotImplemented" and a mute button with no mic to mute would - * mislead users. Re-enabling them is a small UI patch once - * `QuicWebTransportFactory.connect()` produces a real session. + * Speaker-side (mic capture + publish) is M5+ in the audio-rooms completion + * plan — `nestsClient/plans/2026-04-26-audio-rooms-completion.md`. Until then + * the presence event omits the `muted` tag (we have no mic to mute). */ @Composable fun AudioRoomStage( @@ -172,6 +186,12 @@ private fun AudioRoomStageContent( ) } + AudioConnectionRow( + event = event, + hosts = hosts, + accountViewModel = accountViewModel, + ) + Row( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End, @@ -222,6 +242,147 @@ private fun StagePeopleRow( } } +/** + * Connect / state-chip / mute row. Wires [AudioRoomViewModel] for the listener + * audio pipeline. Hidden when the room event has no `service` tag (legacy + * recordings or rooms hosted on non-nests servers — nothing to connect to). + */ +@Composable +private fun AudioConnectionRow( + event: MeetingSpaceEvent, + hosts: List, + accountViewModel: AccountViewModel, +) { + val serviceBase = event.service() + val roomId = event.address().dTag + + if (serviceBase.isNullOrBlank() || roomId.isBlank()) { + Text( + text = stringRes(R.string.audio_room_audio_unavailable), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + return + } + + val signer = accountViewModel.account.signer + val viewModelKey = remember(serviceBase, roomId) { "$serviceBase|$roomId" } + + val viewModel: AudioRoomViewModel = + viewModel( + key = viewModelKey, + factory = + remember(viewModelKey, signer) { + AudioRoomViewModelFactory( + signer = signer, + serviceBase = serviceBase, + roomId = roomId, + ) + }, + ) + + val hostKeys = remember(hosts) { hosts.map { it.pubKey }.toSet() } + LaunchedEffect(viewModel, hostKeys) { + viewModel.updateSpeakers(hostKeys) + } + + val ui by viewModel.uiState.collectAsState() + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + when (val connection = ui.connection) { + is ConnectionUiState.Idle, is ConnectionUiState.Closed -> { + Button(onClick = { viewModel.connect() }) { + Text(stringRes(R.string.audio_room_connect)) + } + } + + is ConnectionUiState.Connecting -> { + AssistChip( + onClick = {}, + enabled = false, + label = { Text(connectingLabel(connection)) }, + ) + } + + is ConnectionUiState.Connected -> { + AssistChip( + onClick = {}, + enabled = false, + label = { Text(stringRes(R.string.audio_room_connected)) }, + colors = + AssistChipDefaults.assistChipColors( + disabledLabelColor = MaterialTheme.colorScheme.primary, + ), + ) + FilledTonalIconToggleButton( + checked = ui.isMuted, + onCheckedChange = { viewModel.setMuted(it) }, + ) { + Icon( + symbol = if (ui.isMuted) MaterialSymbols.AutoMirrored.VolumeOff else MaterialSymbols.AutoMirrored.VolumeUp, + contentDescription = + stringRes( + if (ui.isMuted) R.string.audio_room_unmute else R.string.audio_room_mute, + ), + ) + } + OutlinedButton(onClick = { viewModel.disconnect() }) { + Text(stringRes(R.string.audio_room_disconnect)) + } + } + + is ConnectionUiState.Failed -> { + Text( + text = stringRes(R.string.audio_room_audio_failed, connection.reason), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f, fill = false), + ) + Button(onClick = { viewModel.connect() }) { + Text(stringRes(R.string.audio_room_connect)) + } + } + } + } +} + +@Composable +private fun connectingLabel(connection: ConnectionUiState.Connecting): String = + when (connection.step) { + ConnectionUiState.Step.ResolvingRoom -> stringRes(R.string.audio_room_connecting_resolving) + ConnectionUiState.Step.OpeningTransport -> stringRes(R.string.audio_room_connecting_transport) + ConnectionUiState.Step.MoqHandshake -> stringRes(R.string.audio_room_connecting_handshake) + } + +/** + * Android-side Factory for [AudioRoomViewModel]. The ViewModel itself lives + * in `commons/` so a future desktop port can reuse the orchestration once + * Compose Desktop has WebTransport; this factory binds it to the Android + * actuals (OkHttp HTTP, pure-Kotlin QUIC, MediaCodec Opus, AudioTrack). + */ +private class AudioRoomViewModelFactory( + private val signer: com.vitorpamplona.quartz.nip01Core.signers.NostrSigner, + private val serviceBase: String, + private val roomId: String, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + AudioRoomViewModel( + httpClient = OkHttpNestsClient(), + transport = QuicWebTransportFactory(), + decoderFactory = { MediaCodecOpusDecoder() }, + playerFactory = { AudioTrackPlayer() }, + signer = signer, + serviceBase = serviceBase, + roomId = roomId, + ) as T +} + private const val PRESENCE_REFRESH_MS = 30_000L private suspend fun publishPresence( @@ -234,9 +395,9 @@ private suspend fun publishPresence( MeetingRoomPresenceEvent.build( root = event, handRaised = handRaised, - // muted tag intentionally omitted while audio transport is not - // shipping — we're not producing a mic stream so any muted - // value would be misleading. + // muted tag intentionally omitted — listener-only mute (M1) + // silences our speakers, not a mic we don't yet broadcast. + // Re-evaluate when M5 (publisher path) lands. muted = null, ), ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 50dbf4480..afc098bf1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -493,6 +493,17 @@ Audience Raise hand Lower hand + Connect audio + Disconnect + Mute + Unmute + Connecting… + Resolving room + Opening transport + Negotiating audio + Audio connected + Audio failed: %1$s + Audio is not available for this room Videos Articles Private Bookmarks diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index ec92bda11..61bcf5dbb 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -47,6 +47,11 @@ kotlin { commonMain { dependencies { implementation(project(":quartz")) + // Audio-rooms ViewModel needs the listener orchestration + audio + // pipeline types (NestsListener, AudioRoomPlayer, AudioPlayer + // interface). Concrete OkHttp/Quic/MediaCodec/AudioTrack actuals + // stay in :nestsClient's platform source sets. + implementation(project(":nestsClient")) // Compose Multiplatform implementation(libs.jetbrains.compose.ui) 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 new file mode 100644 index 000000000..5bdcc6400 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt @@ -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 = _uiState.asStateFlow() + + private var listener: NestsListener? = null + private var connectJob: Job? = null + private var stateObserverJob: Job? = null + + private val activeSubscriptions = mutableMapOf() + private var requestedSpeakers: Set = emptySet() + private var closed = false + + /** Push the latest known speaker set from the room event. */ + fun updateSpeakers(speakerPubkeys: Set) { + 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 = 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, + } +} 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 new file mode 100644 index 000000000..157761d5e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt @@ -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(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(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(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(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.Idle) + override val state: StateFlow = 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 sign( + createdAt: Long, + kind: Int, + tags: Array>, + 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") + } +} diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt index 31b26bfa9..d465cbc7c 100644 --- a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt @@ -41,6 +41,7 @@ class AudioTrackPlayer( private val contentType: Int = AudioAttributes.CONTENT_TYPE_SPEECH, ) : AudioPlayer { private var track: AudioTrack? = null + private var muted: Boolean = false override fun start() { if (track != null) return @@ -104,6 +105,7 @@ class AudioTrackPlayer( t, ) } + applyMuteVolume(newTrack, muted) track = newTrack } @@ -122,6 +124,11 @@ class AudioTrackPlayer( } } + override fun setMuted(muted: Boolean) { + this.muted = muted + track?.let { applyMuteVolume(it, muted) } + } + override fun stop() { val t = track ?: return track = null @@ -133,4 +140,14 @@ class AudioTrackPlayer( @Suppress("unused") val voiceCallUsage: Int get() = AudioManager.STREAM_VOICE_CALL // kept for documentation + + private fun applyMuteVolume( + track: AudioTrack, + muted: Boolean, + ) { + // setVolume is preferred over pause(): it keeps the streaming pipeline + // running so unmute is sample-accurate and there's no AudioTrack-restart + // glitch. AudioTrack default gain is 1.0 (RFC: AudioTrack#setVolume). + runCatching { track.setVolume(if (muted) 0f else 1f) } + } } diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt index 61d00f59c..c83c4e81e 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt @@ -110,6 +110,17 @@ interface AudioPlayer { */ suspend fun enqueue(pcm: ShortArray) + /** + * Toggle output silence without halting the underlying decode/network + * pipeline. The producer keeps pushing PCM into [enqueue]; the device + * just stops emitting sound. Default is unmuted. + * + * Used so the audio-room UI mute button is instant — nothing to + * re-handshake on unmute. Setting before [start] is allowed; the value + * is applied when the device opens. + */ + fun setMuted(muted: Boolean) + /** Stop playback and release resources. After this, the player is unusable. */ fun stop() } diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/audio/AudioRoomPlayerTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/audio/AudioRoomPlayerTest.kt index f2918f07c..9023bb887 100644 --- a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/audio/AudioRoomPlayerTest.kt +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/audio/AudioRoomPlayerTest.kt @@ -206,6 +206,8 @@ class AudioRoomPlayerTest { private set val stopped: Boolean get() = stopCount > 0 val queued = mutableListOf() + var muted: Boolean = false + private set override fun start() { started = true @@ -215,6 +217,10 @@ class AudioRoomPlayerTest { queued.add(pcm) } + override fun setMuted(muted: Boolean) { + this.muted = muted + } + override fun stop() { stopCount++ }