Sonos queue resync + cold-start prefetcher gate #83
@@ -8,6 +8,7 @@ import androidx.lifecycle.DefaultLifecycleObserver
|
|||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import androidx.media3.common.ForwardingPlayer
|
import androidx.media3.common.ForwardingPlayer
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||||
@@ -124,6 +125,43 @@ class MinstrelForwardingPlayer(
|
|||||||
private fun isLoadingUpnp(): Boolean =
|
private fun isLoadingUpnp(): Boolean =
|
||||||
holder.target.value != null && holder.active.value == null
|
holder.target.value != null && holder.active.value == null
|
||||||
|
|
||||||
|
// ─── setMediaItems intercepts ──────────────────────────────────────
|
||||||
|
// When PlayerController.setQueue replaces the queue while Sonos is the
|
||||||
|
// active route, the wrapped delegate's queue gets the new items but
|
||||||
|
// Sonos's native queue still holds the OLD tracks -- and the play()
|
||||||
|
// that PlayerController fires immediately after setMediaItems would
|
||||||
|
// resume the old Sonos queue (user reported on-device: "player view
|
||||||
|
// updates but Sonos queue does not"). We clear active + set target
|
||||||
|
// synchronously here so the next play() in the same IPC sequence
|
||||||
|
// drops via isLoadingUpnp() = true; the OutputPickerController
|
||||||
|
// observes the uiState.queue change and runs the resync (re-clears
|
||||||
|
// Sonos's native queue + AddURIToQueue the new tracks + Play).
|
||||||
|
|
||||||
|
override fun setMediaItems(mediaItems: List<MediaItem>) {
|
||||||
|
super.setMediaItems(mediaItems)
|
||||||
|
markPendingResyncIfRemote()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setMediaItems(mediaItems: List<MediaItem>, resetPosition: Boolean) {
|
||||||
|
super.setMediaItems(mediaItems, resetPosition)
|
||||||
|
markPendingResyncIfRemote()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setMediaItems(mediaItems: List<MediaItem>, startIndex: Int, startPositionMs: Long) {
|
||||||
|
super.setMediaItems(mediaItems, startIndex, startPositionMs)
|
||||||
|
markPendingResyncIfRemote()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markPendingResyncIfRemote() {
|
||||||
|
val wasActive = holder.active.value ?: return
|
||||||
|
Timber.w(
|
||||||
|
"setMediaItems while UPnP active (%s) -- marking pending resync",
|
||||||
|
wasActive.routeName,
|
||||||
|
)
|
||||||
|
holder.set(null)
|
||||||
|
holder.setTarget(wasActive.routeId)
|
||||||
|
}
|
||||||
|
|
||||||
override fun play() {
|
override fun play() {
|
||||||
if (isLoadingUpnp()) {
|
if (isLoadingUpnp()) {
|
||||||
Timber.w("ForwardingPlayer.play() dropped -- UPnP loading")
|
Timber.w("ForwardingPlayer.play() dropped -- UPnP loading")
|
||||||
|
|||||||
+86
@@ -139,6 +139,12 @@ class OutputPickerController @Inject constructor(
|
|||||||
) = refresh()
|
) = refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last-synced queue identity (track ids joined). Used by the
|
||||||
|
// observeQueueChangesForSonosResync loop below to detect when the user
|
||||||
|
// has played a different playlist while UPnP is active and re-prime
|
||||||
|
// Sonos's native queue accordingly.
|
||||||
|
private var lastSyncedQueueKey: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Two-arg addCallback registers with no discovery flag —
|
// Two-arg addCallback registers with no discovery flag —
|
||||||
// androidx.mediarouter 1.7.0's default passive behavior:
|
// androidx.mediarouter 1.7.0's default passive behavior:
|
||||||
@@ -154,6 +160,86 @@ class OutputPickerController @Inject constructor(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
playerFactory.dropEvents.collect { handleRemoteDrop() }
|
playerFactory.dropEvents.collect { handleRemoteDrop() }
|
||||||
}
|
}
|
||||||
|
scope.launch { observeQueueChangesForSonosResync() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the user plays a different playlist while Sonos is active,
|
||||||
|
* PlayerController.setQueue replaces the local queue but Sonos's
|
||||||
|
* native queue still holds the OLD tracks. MinstrelForwardingPlayer's
|
||||||
|
* setMediaItems override clears holder.active + sets target so the
|
||||||
|
* imminent play() call drops (drops via isLoadingUpnp() = true). Then
|
||||||
|
* this collector observes the uiState.queue change and re-runs
|
||||||
|
* loadQueueOnSonos to push the new tracks to Sonos.
|
||||||
|
*
|
||||||
|
* Discrimination: selectUpnp's initial-load path doesn't change
|
||||||
|
* uiState.queue (the queue was already populated before route
|
||||||
|
* selection), so this collector doesn't fire during that window. Only
|
||||||
|
* a fresh setQueue from PlayerController bumps the joined-ids key.
|
||||||
|
*/
|
||||||
|
private suspend fun observeQueueChangesForSonosResync() {
|
||||||
|
playerController.uiState.collect { state ->
|
||||||
|
val newKey = state.queue.joinToString("|") { it.id }
|
||||||
|
if (newKey == lastSyncedQueueKey) return@collect
|
||||||
|
lastSyncedQueueKey = newKey
|
||||||
|
// Route can be in target (setMediaItems-induced clearing already
|
||||||
|
// ran in ForwardingPlayer) OR in active (queue changed via
|
||||||
|
// addMediaItem / replaceMediaItems etc. which don't hit the
|
||||||
|
// markPending hook, OR the dispatcher race where the collector
|
||||||
|
// observed uiState before markPendingResyncIfRemote ran).
|
||||||
|
val routeId = activeUpnpHolder.target.value
|
||||||
|
?: activeUpnpHolder.active.value?.routeId
|
||||||
|
?: return@collect
|
||||||
|
if (state.queue.isEmpty()) {
|
||||||
|
Timber.w("Sonos resync skipped: empty queue (clearing target)")
|
||||||
|
activeUpnpHolder.setTarget(null)
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
// Clear active + set target if it wasn't already cleared, so any
|
||||||
|
// transport calls during the resync drop via isLoadingUpnp.
|
||||||
|
if (activeUpnpHolder.active.value != null) {
|
||||||
|
activeUpnpHolder.set(null)
|
||||||
|
activeUpnpHolder.setTarget(routeId)
|
||||||
|
}
|
||||||
|
scope.launch { resyncSonosQueue(routeId, state.queue, state.queueIndex) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resyncSonosQueue(
|
||||||
|
routeId: String,
|
||||||
|
queue: List<TrackRef>,
|
||||||
|
currentIndex: Int,
|
||||||
|
) = selectUpnpMutex.withLock {
|
||||||
|
val upnpRoute = upnpDiscovery.routes.value.firstOrNull { it.id == routeId }
|
||||||
|
val transport = upnpDiscovery.transportFor(routeId)
|
||||||
|
if (upnpRoute == null || transport == null) {
|
||||||
|
Timber.w(
|
||||||
|
"Sonos resync: route or transport gone for %s, dropping to local",
|
||||||
|
routeId,
|
||||||
|
)
|
||||||
|
activeUpnpHolder.setTarget(null)
|
||||||
|
selectedUpnpRouteIdInternal.value = null
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute)
|
||||||
|
val rendering = renderingClientFor(routeId)
|
||||||
|
Timber.w("Sonos resync: reloading %d tracks on %s", queue.size, outputRoute.name)
|
||||||
|
runCatching {
|
||||||
|
loadQueueOnSonos(transport, outputRoute, queue, currentIndex)
|
||||||
|
activeUpnpHolder.set(
|
||||||
|
ActiveUpnp(
|
||||||
|
routeId = routeId,
|
||||||
|
routeName = outputRoute.name,
|
||||||
|
avTransport = transport,
|
||||||
|
rendering = rendering,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
activeUpnpHolder.setTarget(null)
|
||||||
|
}.onFailure { e ->
|
||||||
|
Timber.w(e, "Sonos resync failed -- dropping to local")
|
||||||
|
activeUpnpHolder.setTarget(null)
|
||||||
|
selectedUpnpRouteIdInternal.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user