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
@@ -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()
}
@@ -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++
}