fix(android): UPnP survives screen-off + cursor catches up to Sonos
android / Build + lint + test (push) Has been cancelled

This commit is contained in:
2026-06-03 23:36:43 -04:00
parent b1a66f18bd
commit 8f89279fa4
2 changed files with 36 additions and 11 deletions
@@ -264,16 +264,19 @@ class MinstrelForwardingPlayer(
}
/**
* One poll tick: read position + transport state from Sonos and apply to
* [remoteState]. The local cursor is NOT synced to Sonos's Track index --
* during the 17-second queue load Sonos reports Track=1 (the first added
* track) which would silently walk the local cursor to 0, then SetAV+Seek
* lands and Sonos jumps to the user's actual track, racing with the
* sync. Local + Sonos cursors stay in sync because every override
* (seekToNext/Prev/seekTo) advances both sides by the same delta.
* Hardware-button or Sonos-app driven advances ARE missed until the
* user takes a transport action -- accepted tradeoff pending GENA
* event subscriptions.
* One poll tick: read position + transport state from Sonos, apply to
* [remoteState], and forward-sync the local cursor to Sonos's Track
* index when not in queue load.
*
* Cursor sync is gated on `holder.target == null` (= not loading)
* because during load Sonos reports Track=1 while we're still
* appending, and syncing would race the SetAV+Seek that lands
* after. Outside load, forward sync catches Sonos auto-advances
* (queue end-of-track), Sonos-app driven Next presses, and any
* drift after a brief poll-failure burst that didn't trip the
* drop threshold. Forward-only because a Next override we just
* issued can race with a poll still reporting the prior Track --
* the next poll catches up safely.
*/
private suspend fun pollOnce(active: ActiveUpnp) {
val info = active.avTransport.getPositionInfo()
@@ -283,6 +286,7 @@ class MinstrelForwardingPlayer(
trackUri = info.trackUri,
trackNumber = info.track,
)
maybeSyncLocalCursor(info.track)
val transport = active.avTransport.getTransportInfo()
when (transport.state) {
TransportState.PLAYING -> {
@@ -305,6 +309,22 @@ class MinstrelForwardingPlayer(
}
}
private fun maybeSyncLocalCursor(sonosTrack: Int) {
if (holder.target.value != null) return
if (sonosTrack <= 0) return
val sonosIdx = sonosTrack - 1
handler.post {
val localIdx = delegate.currentMediaItemIndex
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
Timber.w(
"UPnP cursor catch-up: local=%d -> sonos=%d",
localIdx, sonosIdx,
)
delegate.seekTo(sonosIdx, 0L)
}
}
}
private companion object {
const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2
@@ -67,6 +67,11 @@ class RemotePlayerState @Inject constructor() {
}
private companion object {
const val DROP_THRESHOLD = 3
// ~30 seconds of consecutive poll failures before declaring the route
// dropped. Bumped from 3 because screen-off WiFi sleep / brief Doze
// can stall socket I/O for several seconds without the renderer
// actually being unreachable -- a 3-failure drop kicked us back to
// local audio every time the phone went into a pocket.
const val DROP_THRESHOLD = 30
}
}