refactor(nests): extract ActiveSubscription + plan deferred manager refactor (Audit-9, Audit-14)

Audit-9: NestViewModel.kt is 2112 lines and growing, ~1000 of which
are subscription-lifecycle state machine concerns intertwined with
the room-level public API. Pulling out the full
`NestSubscriptionManager` is a multi-week refactor with subtle
coupling (catalog readiness affects spinner state, mute has effective
+ per-speaker flavours, expiry jobs need the parent scope) and
warrants its own focused review pass.

For now, take the small tractable subset:

  - Extract `ActiveSubscription` from a `private inner class` in
    NestViewModel to its own file as `internal class
    ActiveSubscription` in the same package. The class is purely
    state-holding (handle, roomPlayer, player, isPlaying); zero VM
    coupling beyond the slot map's value type. Same visibility for
    NestViewModel callers; one less private helper class buried
    1500 lines into NestViewModel.kt.

  - File `commons/plans/2026-05-06-nest-subscription-manager-extraction.md`
    documents the deferred full extraction: target shape, state
    that moves, methods that move, what stays in VM, why deferred,
    when to land. Picks up the next person who opens NestViewModel.kt
    rather than leaving them to re-derive the rationale.

Audit-14: T11.3 (stream priority for moq-lite group uni streams) was
deferred from the T11 commit (drop bestEffort=true) because the
:quic-side change touches the writer's hot path and warrants its own
review pass. New file `nestsClient/plans/2026-05-06-stream-priority-followup.md`
spells out:

  - Why: bestEffort=true was incidentally biasing drain order toward
    newer groups; without it, the writer's round-robin order can
    serve a stale group when a fresh one is more useful.
  - Target shape: `QuicStream.priority` field, sortedByDescending
    in the writer's send-frame loop, `WebTransportWriteStream.setPriority`
    pass-through, `MoqLiteSession.openGroupStream` calls
    setPriority(sequence). With code sketches.
  - Test: pin iteration order via the writer's emitted-frames tape.
  - Risk profile: starvation, perf cost of per-pass sort, compat.
  - When to land: after interop verification stabilises.

No code changes in this commit beyond the ActiveSubscription move.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
This commit is contained in:
Claude
2026-05-06 20:04:56 +00:00
parent e9d19e5de4
commit 28358b4141
4 changed files with 353 additions and 43 deletions
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.viewmodels
import com.vitorpamplona.nestsclient.audio.AudioPlayer
import com.vitorpamplona.nestsclient.audio.NestPlayer
import com.vitorpamplona.nestsclient.moq.SubscribeHandle
/**
* Per-pubkey state slot held in [NestViewModel.activeSubscriptions].
*
* Three lifecycle phases:
* - **Pending**: just-reserved by `reconcileSubscriptions` to dedupe
* concurrent reconciles. No handle / player attached yet. Constructor
* via [pending].
* - **Active**: [attach] wires the moq subscribe handle, the
* [NestPlayer] decode loop, and the device player. [isPlaying]
* flips true.
* - **Detached**: [detach] returns the handle + roomPlayer pair so the
* caller can run the suspending teardown (`NestPlayer.stop()` +
* `SubscribeHandle.unsubscribe()`) in its own coroutine scope —
* keeps the native MediaCodec / AudioTrack release ordered after
* the decode loop has unwound (audit MoQ #11/#12).
*
* `internal` because only [NestViewModel]'s subscription-lifecycle
* paths (open / close / reconcile / mute / hush) construct or mutate
* these. Lifted to a top-level type rather than a nested class so the
* file split tracks concerns: this class is "subscription state
* machine"; the rest of NestViewModel is "ViewModel public surface +
* orchestration."
*/
internal class ActiveSubscription private constructor(
val pubkey: String,
) {
private var handle: SubscribeHandle? = null
private var roomPlayer: NestPlayer? = null
var player: AudioPlayer? = null
private set
var isPlaying: Boolean = false
private set
fun attach(
handle: SubscribeHandle,
roomPlayer: NestPlayer,
player: AudioPlayer,
) {
this.handle = handle
this.roomPlayer = roomPlayer
this.player = player
this.isPlaying = true
}
/**
* Hand the player + handle back to the caller's coroutine scope —
* `NestPlayer.stop()` and `SubscribeHandle.unsubscribe()` are
* both suspend, and the caller has the right scope to await them
* (so native MediaCodec/AudioTrack release runs after the decode
* loop has unwound, per audit MoQ #11/#12).
*/
fun detach(): Pair<NestPlayer?, SubscribeHandle?> {
isPlaying = false
val p = roomPlayer
val h = handle
roomPlayer = null
handle = null
player = null
return p to h
}
companion object {
fun pending(pubkey: String) = ActiveSubscription(pubkey)
}
}
@@ -42,7 +42,6 @@ import com.vitorpamplona.nestsclient.audio.OpusDecoder
import com.vitorpamplona.nestsclient.audio.OpusEncoder
import com.vitorpamplona.nestsclient.connectReconnectingNestsListener
import com.vitorpamplona.nestsclient.connectReconnectingNestsSpeaker
import com.vitorpamplona.nestsclient.moq.SubscribeHandle
import com.vitorpamplona.nestsclient.transport.WebTransportFactory
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
@@ -1672,48 +1671,8 @@ class NestViewModel(
}
}
private class ActiveSubscription private constructor(
val pubkey: String,
) {
private var handle: SubscribeHandle? = null
private var roomPlayer: NestPlayer? = null
var player: AudioPlayer? = null
private set
var isPlaying: Boolean = false
private set
fun attach(
handle: SubscribeHandle,
roomPlayer: NestPlayer,
player: AudioPlayer,
) {
this.handle = handle
this.roomPlayer = roomPlayer
this.player = player
this.isPlaying = true
}
/**
* Hand the player + handle back to the caller's coroutine scope —
* `NestPlayer.stop()` and `SubscribeHandle.unsubscribe()` are
* both suspend, and the caller has the right scope to await them
* (so native MediaCodec/AudioTrack release runs after the decode
* loop has unwound, per audit MoQ #11/#12).
*/
fun detach(): Pair<NestPlayer?, SubscribeHandle?> {
isPlaying = false
val p = roomPlayer
val h = handle
roomPlayer = null
handle = null
player = null
return p to h
}
companion object {
fun pending(pubkey: String) = ActiveSubscription(pubkey)
}
}
// ActiveSubscription was extracted to its own file in the same
// package — see [ActiveSubscription.kt].
// Platform-specific Factory lives in `amethyst/.../nests/room/`,
// not commonMain — the lifecycle KMP `ViewModelProvider.Factory`