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 * One poll tick: read position + transport state from Sonos, apply to
* [remoteState]. The local cursor is NOT synced to Sonos's Track index -- * [remoteState], and forward-sync the local cursor to Sonos's Track
* during the 17-second queue load Sonos reports Track=1 (the first added * index when not in queue load.
* 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 * Cursor sync is gated on `holder.target == null` (= not loading)
* sync. Local + Sonos cursors stay in sync because every override * because during load Sonos reports Track=1 while we're still
* (seekToNext/Prev/seekTo) advances both sides by the same delta. * appending, and syncing would race the SetAV+Seek that lands
* Hardware-button or Sonos-app driven advances ARE missed until the * after. Outside load, forward sync catches Sonos auto-advances
* user takes a transport action -- accepted tradeoff pending GENA * (queue end-of-track), Sonos-app driven Next presses, and any
* event subscriptions. * 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) { private suspend fun pollOnce(active: ActiveUpnp) {
val info = active.avTransport.getPositionInfo() val info = active.avTransport.getPositionInfo()
@@ -283,6 +286,7 @@ class MinstrelForwardingPlayer(
trackUri = info.trackUri, trackUri = info.trackUri,
trackNumber = info.track, trackNumber = info.track,
) )
maybeSyncLocalCursor(info.track)
val transport = active.avTransport.getTransportInfo() val transport = active.avTransport.getTransportInfo()
when (transport.state) { when (transport.state) {
TransportState.PLAYING -> { 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 { private companion object {
const val POLL_INTERVAL_MS = 1_000L const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2 const val NON_PLAYING_CONFIRM = 2
@@ -67,6 +67,11 @@ class RemotePlayerState @Inject constructor() {
} }
private companion object { 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
} }
} }