refactor(nests): extract MoqLiteBroadcastHandle + HotSwappablePublisherSource (Audit-10)
`MoqLiteNestsSpeaker.kt` mixed three top-level concerns at 387 lines:
- The `MoqLiteNestsSpeaker` class itself — the speaker entry point
that builds a publisher + broadcaster pair on
`startBroadcasting`.
- `MoqLiteBroadcastHandle` — internal `BroadcastHandle`
implementation tying the broadcaster, audio publisher, and
catalog publisher together with a fixed-order shutdown.
- `HotSwappablePublisherSource` — internal interface that lets
`ReconnectingNestsSpeaker.runHotSwapIteration` retarget a
long-lived broadcaster onto fresh moq-lite session publishers
without restarting the AudioRecord / Opus encoder pipeline.
The handle and the interface are independently reachable from
`ReconnectingNestsSpeaker` and have no behavioural coupling to
`MoqLiteNestsSpeaker` beyond a `parent` reference (handle) or an
`as?` cast (interface). Move each to its own file:
- `MoqLiteBroadcastHandle.kt` (109 lines).
- `HotSwappablePublisherSource.kt` (62 lines).
`MoqLiteNestsSpeaker.kt` is now 276 lines focused on the speaker
class. Same package, same `internal` visibility — no call-site
changes needed elsewhere. Tests + spotless green.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
This commit is contained in:
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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.nestsclient
|
||||
|
||||
import com.vitorpamplona.nestsclient.moq.lite.MoqLitePublisherHandle
|
||||
|
||||
/**
|
||||
* Internal hot-swap seam: speakers that expose this interface let the
|
||||
* reconnect wrapper retarget a long-lived
|
||||
* [com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster] onto a
|
||||
* freshly-opened moq-lite session's publisher without restarting the
|
||||
* AudioRecord / Opus encoder pipeline. Implemented by
|
||||
* [MoqLiteNestsSpeaker]; not implemented by the IETF reference
|
||||
* [DefaultNestsSpeaker], which falls back to the close-then-restart path
|
||||
* inside [com.vitorpamplona.nestsclient.connectReconnectingNestsSpeaker].
|
||||
*
|
||||
* The wrapper uses an `as?` cast to detect support so this interface
|
||||
* can stay package-internal — protocol consumers never see it.
|
||||
*/
|
||||
internal interface HotSwappablePublisherSource {
|
||||
/**
|
||||
* Open a fresh [MoqLitePublisherHandle] on the underlying moq-lite
|
||||
* session. Caller owns the returned handle's lifetime (typically
|
||||
* via [com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster.swapPublisher]'s
|
||||
* close-the-old contract).
|
||||
*
|
||||
* @param startSequence first group sequence the new publisher will
|
||||
* assign. Used by the hot-swap path to seed the new session's
|
||||
* audio track with the previous session's
|
||||
* [MoqLitePublisherHandle.nextSequence] so kixelated/hang's
|
||||
* `Container.Consumer.#run` doesn't drop every post-recycle
|
||||
* group as `sequence < #active`. Pass `0L` for fresh (non-
|
||||
* continuation) publishers — the catalog track is one such
|
||||
* case, since its `#active` semantics are different from audio.
|
||||
*/
|
||||
suspend fun openPublisherForHotSwap(
|
||||
track: String,
|
||||
startSequence: Long = 0L,
|
||||
): MoqLitePublisherHandle
|
||||
|
||||
/**
|
||||
* Surface a broadcast-pipeline terminal failure (e.g. sustained
|
||||
* `publisher.send` errors past
|
||||
* [com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster.MAX_CONSECUTIVE_SEND_ERRORS])
|
||||
* by flipping the speaker's state to [NestsSpeakerState.Failed].
|
||||
* Called by the hot-swap pump when the long-lived broadcaster's
|
||||
* `onTerminalFailure` fires; lets the reconnect orchestrator
|
||||
* observe the terminal state and recycle the session, matching
|
||||
* the legacy
|
||||
* [MoqLiteNestsSpeaker.startBroadcasting] path's failure
|
||||
* propagation.
|
||||
*/
|
||||
fun reportBroadcastTerminalFailure()
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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.nestsclient
|
||||
|
||||
import com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster
|
||||
import com.vitorpamplona.nestsclient.moq.lite.MoqLitePublisherHandle
|
||||
|
||||
/**
|
||||
* [BroadcastHandle] returned by [MoqLiteNestsSpeaker.startBroadcasting]
|
||||
* for the non-reconnecting code path. Wraps:
|
||||
*
|
||||
* - the long-lived audio [broadcaster] (mic + encoder + send loop),
|
||||
* - the audio-track [publisher] on the moq-lite session,
|
||||
* - the [catalogPublisher] companion track that emits the
|
||||
* `catalog.json` manifest on every new SUBSCRIBE.
|
||||
*
|
||||
* Mute is forwarded to the broadcaster (which also reports the user-
|
||||
* facing state back via [parent.reportMuteState]). [close] tears down
|
||||
* all three components in a fixed order — broadcaster → audio
|
||||
* publisher → catalog publisher — so a `CancellationException`
|
||||
* mid-shutdown still releases every resource before re-throwing.
|
||||
*
|
||||
* Reconnecting / hot-swap callers go through `ReissuingBroadcastHandle`
|
||||
* in `ReconnectingNestsSpeaker` instead, which manages the audio +
|
||||
* catalog publishers across session swaps.
|
||||
*/
|
||||
internal class MoqLiteBroadcastHandle(
|
||||
private val broadcaster: NestMoqLiteBroadcaster,
|
||||
private val publisher: MoqLitePublisherHandle,
|
||||
private val catalogPublisher: MoqLitePublisherHandle,
|
||||
private val parent: MoqLiteNestsSpeaker,
|
||||
) : BroadcastHandle {
|
||||
@Volatile private var muted: Boolean = false
|
||||
|
||||
@Volatile private var closed: Boolean = false
|
||||
|
||||
override val isMuted: Boolean get() = muted
|
||||
|
||||
override suspend fun setMuted(muted: Boolean) {
|
||||
if (closed) return
|
||||
this.muted = muted
|
||||
broadcaster.setMuted(muted)
|
||||
parent.reportMuteState(muted)
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
// Stop the broadcaster first so the audio capture + encoder
|
||||
// don't keep producing into a closing publisher.
|
||||
try {
|
||||
broadcaster.stop()
|
||||
} catch (ce: kotlinx.coroutines.CancellationException) {
|
||||
// Even on cancel, run the rest of cleanup before rethrowing
|
||||
// — broadcaster.stop already cancels its own job, so the
|
||||
// mic + encoder + publisher are owed their close paths.
|
||||
runCatching { catalogPublisher.close() }
|
||||
runCatching { publisher.close() }
|
||||
parent.broadcastClosed(this)
|
||||
throw ce
|
||||
} catch (_: Throwable) {
|
||||
// Best-effort; fall through to the defensive publisher.close.
|
||||
}
|
||||
// broadcaster.stop() already calls publisher.close(); call again
|
||||
// defensively to make this method idempotent against partial
|
||||
// failures on the broadcaster.stop path.
|
||||
try {
|
||||
publisher.close()
|
||||
} catch (ce: kotlinx.coroutines.CancellationException) {
|
||||
runCatching { catalogPublisher.close() }
|
||||
parent.broadcastClosed(this)
|
||||
throw ce
|
||||
} catch (_: Throwable) {
|
||||
// Best-effort.
|
||||
}
|
||||
try {
|
||||
catalogPublisher.close()
|
||||
} catch (ce: kotlinx.coroutines.CancellationException) {
|
||||
parent.broadcastClosed(this)
|
||||
throw ce
|
||||
} catch (_: Throwable) {
|
||||
// Best-effort.
|
||||
}
|
||||
parent.broadcastClosed(this)
|
||||
}
|
||||
}
|
||||
-110
@@ -274,113 +274,3 @@ class MoqLiteNestsSpeaker internal constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class MoqLiteBroadcastHandle(
|
||||
private val broadcaster: NestMoqLiteBroadcaster,
|
||||
private val publisher: MoqLitePublisherHandle,
|
||||
private val catalogPublisher: MoqLitePublisherHandle,
|
||||
private val parent: MoqLiteNestsSpeaker,
|
||||
) : BroadcastHandle {
|
||||
@Volatile private var muted: Boolean = false
|
||||
|
||||
@Volatile private var closed: Boolean = false
|
||||
|
||||
override val isMuted: Boolean get() = muted
|
||||
|
||||
override suspend fun setMuted(muted: Boolean) {
|
||||
if (closed) return
|
||||
this.muted = muted
|
||||
broadcaster.setMuted(muted)
|
||||
parent.reportMuteState(muted)
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
// Stop the broadcaster first so the audio capture + encoder
|
||||
// don't keep producing into a closing publisher.
|
||||
try {
|
||||
broadcaster.stop()
|
||||
} catch (ce: kotlinx.coroutines.CancellationException) {
|
||||
// Even on cancel, run the rest of cleanup before rethrowing
|
||||
// — broadcaster.stop already cancels its own job, so the
|
||||
// mic + encoder + publisher are owed their close paths.
|
||||
runCatching { catalogPublisher.close() }
|
||||
runCatching { publisher.close() }
|
||||
parent.broadcastClosed(this)
|
||||
throw ce
|
||||
} catch (_: Throwable) {
|
||||
// Best-effort; fall through to the defensive publisher.close.
|
||||
}
|
||||
// broadcaster.stop() already calls publisher.close(); call again
|
||||
// defensively to make this method idempotent against partial
|
||||
// failures on the broadcaster.stop path.
|
||||
try {
|
||||
publisher.close()
|
||||
} catch (ce: kotlinx.coroutines.CancellationException) {
|
||||
runCatching { catalogPublisher.close() }
|
||||
parent.broadcastClosed(this)
|
||||
throw ce
|
||||
} catch (_: Throwable) {
|
||||
// Best-effort.
|
||||
}
|
||||
try {
|
||||
catalogPublisher.close()
|
||||
} catch (ce: kotlinx.coroutines.CancellationException) {
|
||||
parent.broadcastClosed(this)
|
||||
throw ce
|
||||
} catch (_: Throwable) {
|
||||
// Best-effort.
|
||||
}
|
||||
parent.broadcastClosed(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal hot-swap seam: speakers that expose this interface let the
|
||||
* reconnect wrapper retarget a long-lived
|
||||
* [com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster] onto a
|
||||
* freshly-opened moq-lite session's publisher without restarting the
|
||||
* AudioRecord / Opus encoder pipeline. Implemented by
|
||||
* [MoqLiteNestsSpeaker]; not implemented by the IETF reference
|
||||
* [DefaultNestsSpeaker], which falls back to the close-then-restart path
|
||||
* inside [com.vitorpamplona.nestsclient.connectReconnectingNestsSpeaker].
|
||||
*
|
||||
* The wrapper uses an `as?` cast to detect support so this interface
|
||||
* can stay package-internal — protocol consumers never see it.
|
||||
*/
|
||||
internal interface HotSwappablePublisherSource {
|
||||
/**
|
||||
* Open a fresh [MoqLitePublisherHandle] on the underlying moq-lite
|
||||
* session. Caller owns the returned handle's lifetime (typically
|
||||
* via [com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster.swapPublisher]'s
|
||||
* close-the-old contract).
|
||||
*
|
||||
* @param startSequence first group sequence the new publisher will
|
||||
* assign. Used by the hot-swap path to seed the new session's
|
||||
* audio track with the previous session's
|
||||
* [MoqLitePublisherHandle.nextSequence] so kixelated/hang's
|
||||
* `Container.Consumer.#run` doesn't drop every post-recycle
|
||||
* group as `sequence < #active`. Pass `0L` for fresh (non-
|
||||
* continuation) publishers — the catalog track is one such
|
||||
* case, since its `#active` semantics are different from audio.
|
||||
*/
|
||||
suspend fun openPublisherForHotSwap(
|
||||
track: String,
|
||||
startSequence: Long = 0L,
|
||||
): MoqLitePublisherHandle
|
||||
|
||||
/**
|
||||
* Surface a broadcast-pipeline terminal failure (e.g. sustained
|
||||
* `publisher.send` errors past
|
||||
* [com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster.MAX_CONSECUTIVE_SEND_ERRORS])
|
||||
* by flipping the speaker's state to [NestsSpeakerState.Failed].
|
||||
* Called by the hot-swap pump when the long-lived broadcaster's
|
||||
* `onTerminalFailure` fires; lets the reconnect orchestrator
|
||||
* observe the terminal state and recycle the session, matching
|
||||
* the legacy
|
||||
* [MoqLiteNestsSpeaker.startBroadcasting] path's failure
|
||||
* propagation.
|
||||
*/
|
||||
fun reportBroadcastTerminalFailure()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user