fix(android): defer holder.active until SOAP wired -- drop transport during load
android / Build + lint + test (push) Successful in 4m34s
android / Build + lint + test (push) Successful in 4m34s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -61,7 +61,20 @@ class MinstrelForwardingPlayer(
|
|||||||
|
|
||||||
private fun isRemote(): Boolean = holder.active.value != null
|
private fun isRemote(): Boolean = holder.active.value != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when selectUpnp has marked a UPnP route as the intended
|
||||||
|
* target but loadQueueOnSonos hasn't yet wired ActiveUpnp. During this
|
||||||
|
* window we drop transport commands silently -- they would hit Sonos's
|
||||||
|
* stale state from a prior session and trigger restarts.
|
||||||
|
*/
|
||||||
|
private fun isLoadingUpnp(): Boolean =
|
||||||
|
holder.target.value != null && holder.active.value == null
|
||||||
|
|
||||||
override fun play() {
|
override fun play() {
|
||||||
|
if (isLoadingUpnp()) {
|
||||||
|
Timber.w("ForwardingPlayer.play() dropped -- UPnP loading")
|
||||||
|
return
|
||||||
|
}
|
||||||
val active = holder.active.value
|
val active = holder.active.value
|
||||||
Timber.w("ForwardingPlayer.play() active=%s", active?.routeName)
|
Timber.w("ForwardingPlayer.play() active=%s", active?.routeName)
|
||||||
if (active == null) {
|
if (active == null) {
|
||||||
@@ -76,6 +89,10 @@ class MinstrelForwardingPlayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun pause() {
|
override fun pause() {
|
||||||
|
if (isLoadingUpnp()) {
|
||||||
|
Timber.w("ForwardingPlayer.pause() dropped -- UPnP loading")
|
||||||
|
return
|
||||||
|
}
|
||||||
val active = holder.active.value
|
val active = holder.active.value
|
||||||
Timber.w("ForwardingPlayer.pause() active=%s", active?.routeName)
|
Timber.w("ForwardingPlayer.pause() active=%s", active?.routeName)
|
||||||
if (active == null) {
|
if (active == null) {
|
||||||
@@ -90,6 +107,10 @@ class MinstrelForwardingPlayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun seekTo(positionMs: Long) {
|
override fun seekTo(positionMs: Long) {
|
||||||
|
if (isLoadingUpnp()) {
|
||||||
|
Timber.w("ForwardingPlayer.seekTo(positionMs) dropped -- UPnP loading")
|
||||||
|
return
|
||||||
|
}
|
||||||
val active = holder.active.value
|
val active = holder.active.value
|
||||||
Timber.w("ForwardingPlayer.seekTo(%dms) active=%s", positionMs, active?.routeName)
|
Timber.w("ForwardingPlayer.seekTo(%dms) active=%s", positionMs, active?.routeName)
|
||||||
if (active == null) {
|
if (active == null) {
|
||||||
@@ -114,6 +135,10 @@ class MinstrelForwardingPlayer(
|
|||||||
* [positionMs] is non-zero.
|
* [positionMs] is non-zero.
|
||||||
*/
|
*/
|
||||||
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
|
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
|
||||||
|
if (isLoadingUpnp()) {
|
||||||
|
Timber.w("ForwardingPlayer.seekTo(idx, positionMs) dropped -- UPnP loading")
|
||||||
|
return
|
||||||
|
}
|
||||||
val active = holder.active.value
|
val active = holder.active.value
|
||||||
Timber.w(
|
Timber.w(
|
||||||
"ForwardingPlayer.seekTo(idx=%d, %dms) active=%s",
|
"ForwardingPlayer.seekTo(idx=%d, %dms) active=%s",
|
||||||
@@ -135,6 +160,10 @@ class MinstrelForwardingPlayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun seekToNextMediaItem() {
|
override fun seekToNextMediaItem() {
|
||||||
|
if (isLoadingUpnp()) {
|
||||||
|
Timber.w("ForwardingPlayer.seekToNextMediaItem() dropped -- UPnP loading")
|
||||||
|
return
|
||||||
|
}
|
||||||
val active = holder.active.value
|
val active = holder.active.value
|
||||||
Timber.w("ForwardingPlayer.seekToNextMediaItem() active=%s", active?.routeName)
|
Timber.w("ForwardingPlayer.seekToNextMediaItem() active=%s", active?.routeName)
|
||||||
if (active == null) {
|
if (active == null) {
|
||||||
@@ -152,6 +181,10 @@ class MinstrelForwardingPlayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun seekToPreviousMediaItem() {
|
override fun seekToPreviousMediaItem() {
|
||||||
|
if (isLoadingUpnp()) {
|
||||||
|
Timber.w("ForwardingPlayer.seekToPreviousMediaItem() dropped -- UPnP loading")
|
||||||
|
return
|
||||||
|
}
|
||||||
val active = holder.active.value
|
val active = holder.active.value
|
||||||
Timber.w("ForwardingPlayer.seekToPreviousMediaItem() active=%s", active?.routeName)
|
Timber.w("ForwardingPlayer.seekToPreviousMediaItem() active=%s", active?.routeName)
|
||||||
if (active == null) {
|
if (active == null) {
|
||||||
|
|||||||
@@ -322,12 +322,15 @@ class PlayerController @Inject constructor(
|
|||||||
* and advance past the dead track. Otherwise no-op.
|
* and advance past the dead track. Otherwise no-op.
|
||||||
*/
|
*/
|
||||||
private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) {
|
private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) {
|
||||||
// Skip during UPnP playback. ExoPlayer is intentionally paused and never
|
// Skip during UPnP playback (active) AND during the activation load
|
||||||
// buffers tracks, so duration is permanently 0 / TIME_UNSET -- without
|
// window (target set, active not yet wired). ExoPlayer is intentionally
|
||||||
// this guard we rapid-advance through the entire local queue (and spam
|
// paused throughout both windows so its STATE_READY duration is always
|
||||||
// /api/playback-errors) every time the user activates a UPnP route.
|
// 0 / TIME_UNSET -- without this guard we rapid-advance through the
|
||||||
|
// entire local queue (and spam /api/playback-errors).
|
||||||
val current = queueRefs.getOrNull(idx)
|
val current = queueRefs.getOrNull(idx)
|
||||||
if (current == null || activeUpnpHolder.active.value != null) return
|
val upnpEngaged = activeUpnpHolder.active.value != null ||
|
||||||
|
activeUpnpHolder.target.value != null
|
||||||
|
if (current == null || upnpEngaged) return
|
||||||
val duration = controller.duration
|
val duration = controller.duration
|
||||||
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
||||||
if (!isZeroDuration) return
|
if (!isZeroDuration) return
|
||||||
|
|||||||
@@ -23,7 +23,21 @@ data class ActiveUpnp(
|
|||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class ActiveUpnpHolder @Inject constructor() {
|
class ActiveUpnpHolder @Inject constructor() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<ActiveUpnp?>(null)
|
private val internal = MutableStateFlow<ActiveUpnp?>(null)
|
||||||
val active: StateFlow<ActiveUpnp?> = internal.asStateFlow()
|
val active: StateFlow<ActiveUpnp?> = internal.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending route id during selectUpnp's queue-load window. Set when
|
||||||
|
* loadQueueOnSonos starts; cleared on completion (success or failure).
|
||||||
|
* ForwardingPlayer overrides treat (target != null && active == null)
|
||||||
|
* as "UPnP intended but SOAP not yet wired" -- drop transport commands
|
||||||
|
* silently rather than send them to a half-loaded queue.
|
||||||
|
*/
|
||||||
|
private val targetInternal = MutableStateFlow<String?>(null)
|
||||||
|
val target: StateFlow<String?> = targetInternal.asStateFlow()
|
||||||
|
|
||||||
fun set(active: ActiveUpnp?) { internal.value = active }
|
fun set(active: ActiveUpnp?) { internal.value = active }
|
||||||
|
|
||||||
|
fun setTarget(routeId: String?) { targetInternal.value = routeId }
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-12
@@ -166,6 +166,7 @@ class OutputPickerController @Inject constructor(
|
|||||||
val capturedPositionMs = remoteState.positionMs
|
val capturedPositionMs = remoteState.positionMs
|
||||||
val wasPlayingRemote = remoteState.isPlaying
|
val wasPlayingRemote = remoteState.isPlaying
|
||||||
activeUpnpHolder.set(null)
|
activeUpnpHolder.set(null)
|
||||||
|
activeUpnpHolder.setTarget(null)
|
||||||
selectedUpnpRouteIdInternal.value = null
|
selectedUpnpRouteIdInternal.value = null
|
||||||
playerController.seekTo(capturedPositionMs)
|
playerController.seekTo(capturedPositionMs)
|
||||||
if (wasPlayingRemote) playerController.play()
|
if (wasPlayingRemote) playerController.play()
|
||||||
@@ -221,6 +222,7 @@ class OutputPickerController @Inject constructor(
|
|||||||
runCatching { active?.avTransport?.stop() }
|
runCatching { active?.avTransport?.stop() }
|
||||||
.onFailure { Timber.w(it, "UPnP Stop failed during disconnect") }
|
.onFailure { Timber.w(it, "UPnP Stop failed during disconnect") }
|
||||||
activeUpnpHolder.set(null)
|
activeUpnpHolder.set(null)
|
||||||
|
activeUpnpHolder.setTarget(null)
|
||||||
selectedUpnpRouteIdInternal.value = null
|
selectedUpnpRouteIdInternal.value = null
|
||||||
playerController.seekTo(capturedPositionMs)
|
playerController.seekTo(capturedPositionMs)
|
||||||
if (wasPlayingRemote) playerController.play()
|
if (wasPlayingRemote) playerController.play()
|
||||||
@@ -235,10 +237,14 @@ class OutputPickerController @Inject constructor(
|
|||||||
* clear the device's queue, load every track from our local queue
|
* clear the device's queue, load every track from our local queue
|
||||||
* via AddURIToQueue, point the transport at the queue URI, seek to
|
* via AddURIToQueue, point the transport at the queue URI, seek to
|
||||||
* the current index, and play. Wrapped in `runCatching` — SOAP
|
* the current index, and play. Wrapped in `runCatching` — SOAP
|
||||||
* failure abandons the selection cleanly. Local pause is handled by
|
* failure abandons the selection cleanly.
|
||||||
* MinstrelForwardingPlayer.onActiveChanged via holder.set(), so we
|
*
|
||||||
* do not call playerController.pause() here (it would race with the
|
* Order of operations is deliberate:
|
||||||
* SOAP override once holder.active is non-null).
|
* 1. Pause local so the user doesn't keep hearing local audio.
|
||||||
|
* 2. Set target early so ForwardingPlayer drops transport taps
|
||||||
|
* while the 17-second queue load is in progress.
|
||||||
|
* 3. Wire active LAST (after loadQueueOnSonos) so SOAP commands
|
||||||
|
* are never routed to a half-loaded Sonos queue.
|
||||||
*/
|
*/
|
||||||
private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock {
|
private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock {
|
||||||
val uiState = playerController.uiState.value
|
val uiState = playerController.uiState.value
|
||||||
@@ -262,20 +268,29 @@ class OutputPickerController @Inject constructor(
|
|||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
val rendering = renderingClientFor(effectiveRoute.id)
|
val rendering = renderingClientFor(effectiveRoute.id)
|
||||||
|
// Pause local before flipping UI state -- user shouldn't keep hearing
|
||||||
|
// local audio while we queue up Sonos.
|
||||||
|
playerController.pause()
|
||||||
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
||||||
activeUpnpHolder.set(
|
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
||||||
ActiveUpnp(
|
// silently while target is set but active is null -- the user's premature
|
||||||
routeId = effectiveRoute.id,
|
// taps don't hit Sonos's stale state from a prior session.
|
||||||
routeName = effectiveRoute.name,
|
activeUpnpHolder.setTarget(effectiveRoute.id)
|
||||||
avTransport = transport,
|
|
||||||
rendering = rendering,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
runCatching {
|
runCatching {
|
||||||
loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
|
loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
|
||||||
|
// Wire active LAST -- SOAP path is now safe to use.
|
||||||
|
activeUpnpHolder.set(
|
||||||
|
ActiveUpnp(
|
||||||
|
routeId = effectiveRoute.id,
|
||||||
|
routeName = effectiveRoute.name,
|
||||||
|
avTransport = transport,
|
||||||
|
rendering = rendering,
|
||||||
|
),
|
||||||
|
)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
|
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
|
||||||
activeUpnpHolder.set(null)
|
activeUpnpHolder.set(null)
|
||||||
|
activeUpnpHolder.setTarget(null)
|
||||||
selectedUpnpRouteIdInternal.value = null
|
selectedUpnpRouteIdInternal.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user