From 8f89279fa47bc07e9350466ba812942af6f24e62 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 23:36:43 -0400 Subject: [PATCH] fix(android): UPnP survives screen-off + cursor catches up to Sonos --- .../player/MinstrelForwardingPlayer.kt | 40 ++++++++++++++----- .../minstrel/player/RemotePlayerState.kt | 7 +++- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index db857765..cc2e74ae 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt index 40afc914..fdbd5fcc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt @@ -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 } }