Sonos queue resync + cold-start prefetcher gate #83

Merged
bvandeusen merged 3 commits from dev into main 2026-06-04 17:36:31 -04:00
2 changed files with 124 additions and 0 deletions
Showing only changes of commit 8017934334 - Show all commits
@@ -8,6 +8,7 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
@@ -124,6 +125,43 @@ class MinstrelForwardingPlayer(
private fun isLoadingUpnp(): Boolean =
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() {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.play() dropped -- UPnP loading")
@@ -139,6 +139,12 @@ class OutputPickerController @Inject constructor(
) = 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 {
// Two-arg addCallback registers with no discovery flag —
// androidx.mediarouter 1.7.0's default passive behavior:
@@ -154,6 +160,86 @@ class OutputPickerController @Inject constructor(
scope.launch {
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
}
}
/**