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:
+174
-13
@@ -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<ParticipantTag>,
|
||||
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 <T : ViewModel> create(modelClass: Class<T>): 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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -493,6 +493,17 @@
|
||||
<string name="audio_room_audience">Audience</string>
|
||||
<string name="audio_room_raise_hand">Raise hand</string>
|
||||
<string name="audio_room_lower_hand">Lower hand</string>
|
||||
<string name="audio_room_connect">Connect audio</string>
|
||||
<string name="audio_room_disconnect">Disconnect</string>
|
||||
<string name="audio_room_mute">Mute</string>
|
||||
<string name="audio_room_unmute">Unmute</string>
|
||||
<string name="audio_room_connecting">Connecting…</string>
|
||||
<string name="audio_room_connecting_resolving">Resolving room</string>
|
||||
<string name="audio_room_connecting_transport">Opening transport</string>
|
||||
<string name="audio_room_connecting_handshake">Negotiating audio</string>
|
||||
<string name="audio_room_connected">Audio connected</string>
|
||||
<string name="audio_room_audio_failed">Audio failed: %1$s</string>
|
||||
<string name="audio_room_audio_unavailable">Audio is not available for this room</string>
|
||||
<string name="longs">Videos</string>
|
||||
<string name="articles">Articles</string>
|
||||
<string name="private_bookmarks">Private Bookmarks</string>
|
||||
|
||||
@@ -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)
|
||||
|
||||
+417
@@ -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,
|
||||
}
|
||||
}
|
||||
+295
@@ -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")
|
||||
}
|
||||
}
|
||||
+17
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+6
@@ -206,6 +206,8 @@ class AudioRoomPlayerTest {
|
||||
private set
|
||||
val stopped: Boolean get() = stopCount > 0
|
||||
val queued = mutableListOf<ShortArray>()
|
||||
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++
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user