From 36054506c2b9c6b55773c8b60025755b3c92b661 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 06:46:38 -0400 Subject: [PATCH] fix(android): polling tick owns track-change updates when delegate.seekTo silent --- .../minstrel/player/PlayerController.kt | 95 +++++++++++-------- 1 file changed, 54 insertions(+), 41 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index f9ee7ed1..87318f66 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -497,45 +497,64 @@ class PlayerController @Inject constructor( scope.launch(Dispatchers.Main.immediate) { while (isActive) { delay(POSITION_POLL_INTERVAL_MS) - val upnpActive = activeUpnpHolder.active.value != null - // Track-alignment gate: if Sonos's reported Track is past the - // wrapped player's currentMediaItemIndex, a forward cursor sync - // is in flight (pollOnce wrote new remoteState, handler.post'd - // delegate.seekTo; the seek hasn't run yet). Skipping the tick - // here lets onMediaItemTransition rebuild the full uiState - // atomically rather than patching position+duration while - // currentTrack/queueIndex still point at the old track. - if (upnpActive && isForwardCursorSyncPending(controller)) continue - val effectiveIsPlaying = - if (upnpActive) remoteState.isPlaying else controller.isPlaying - val effectivePosition = if (upnpActive) { - interpolatedRemotePosition(effectiveIsPlaying) - } else { - controller.currentPosition - } - val current = uiStateInternal.value - val newPos = effectivePosition.coerceAtLeast(0) - val newDur = effectiveDuration( - upnpActive, - remoteState.durationMs, - controller.duration, - ) - val newBuf = controller.bufferedPosition.coerceAtLeast(0) - val changed = current.isPlaying != effectiveIsPlaying || - current.positionMs != newPos || - current.durationMs != newDur - if (changed) { - uiStateInternal.value = current.copy( - isPlaying = effectiveIsPlaying, - positionMs = newPos, - durationMs = newDur, - bufferedPositionMs = newBuf, - ) - } + tickPositionPoll(controller) } } } + /** + * One position-polling tick. Owns track-change detection too: when UPnP + * is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from + * `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving + * uiState.queueIndex stuck on the old track even after Sonos has + * advanced. So the tick reads Sonos's reported Track as the source of + * truth, rebuilds the index/title fields itself, and force-syncs the + * wrapped player as defense in depth. + */ + private fun tickPositionPoll(controller: MediaController) { + val upnpActive = activeUpnpHolder.active.value != null + val effectiveIsPlaying = + if (upnpActive) remoteState.isPlaying else controller.isPlaying + val effectivePosition = if (upnpActive) { + interpolatedRemotePosition(effectiveIsPlaying) + } else { + controller.currentPosition + } + val desiredIdx = desiredQueueIndex(controller, upnpActive) + val current = uiStateInternal.value + val newPos = effectivePosition.coerceAtLeast(0) + val newDur = effectiveDuration(upnpActive, remoteState.durationMs, controller.duration) + val newBuf = controller.bufferedPosition.coerceAtLeast(0) + val trackChanged = desiredIdx != current.queueIndex && desiredIdx in queueRefs.indices + val somethingChanged = trackChanged || + current.isPlaying != effectiveIsPlaying || + current.positionMs != newPos || + current.durationMs != newDur + if (!somethingChanged) return + val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack + val newIdx = if (trackChanged) desiredIdx else current.queueIndex + uiStateInternal.value = current.copy( + currentTrack = newTrack, + queueIndex = newIdx, + isPlaying = effectiveIsPlaying, + positionMs = newPos, + durationMs = newDur, + bufferedPositionMs = newBuf, + ) + if (trackChanged && controller.currentMediaItemIndex != desiredIdx) { + // Defense in depth: keep wrapped player aligned even when + // maybeSyncLocalCursor's seekTo didn't fire a transition event. + controller.seekTo(desiredIdx, 0L) + } + } + + private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int = + if (upnpActive) { + (remoteState.trackNumber - 1).coerceAtLeast(0) + } else { + controller.currentMediaItemIndex + } + // ── Remote position interpolation state ────────────────────────────── // remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz // SOAP poll (and only when the round-trip completes -- screen-off WiFi @@ -547,12 +566,6 @@ class PlayerController @Inject constructor( @Volatile private var positionAnchorMs: Long = 0L @Volatile private var positionAnchorAtRealtimeMs: Long = 0L - private fun isForwardCursorSyncPending(controller: MediaController): Boolean { - val sonosTrack = remoteState.trackNumber - if (sonosTrack <= 0) return false - return (sonosTrack - 1) > controller.currentMediaItemIndex - } - private fun interpolatedRemotePosition(isPlaying: Boolean): Long { val raw = remoteState.positionMs val now = SystemClock.elapsedRealtime()