fix(android): polling tick owns track-change updates when delegate.seekTo silent
android / Build + lint + test (push) Successful in 3m26s

This commit is contained in:
2026-06-04 06:46:38 -04:00
parent 5db90844cb
commit 36054506c2
@@ -497,15 +497,22 @@ class PlayerController @Inject constructor(
scope.launch(Dispatchers.Main.immediate) { scope.launch(Dispatchers.Main.immediate) {
while (isActive) { while (isActive) {
delay(POSITION_POLL_INTERVAL_MS) delay(POSITION_POLL_INTERVAL_MS)
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 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 = val effectiveIsPlaying =
if (upnpActive) remoteState.isPlaying else controller.isPlaying if (upnpActive) remoteState.isPlaying else controller.isPlaying
val effectivePosition = if (upnpActive) { val effectivePosition = if (upnpActive) {
@@ -513,27 +520,39 @@ class PlayerController @Inject constructor(
} else { } else {
controller.currentPosition controller.currentPosition
} }
val desiredIdx = desiredQueueIndex(controller, upnpActive)
val current = uiStateInternal.value val current = uiStateInternal.value
val newPos = effectivePosition.coerceAtLeast(0) val newPos = effectivePosition.coerceAtLeast(0)
val newDur = effectiveDuration( val newDur = effectiveDuration(upnpActive, remoteState.durationMs, controller.duration)
upnpActive,
remoteState.durationMs,
controller.duration,
)
val newBuf = controller.bufferedPosition.coerceAtLeast(0) val newBuf = controller.bufferedPosition.coerceAtLeast(0)
val changed = current.isPlaying != effectiveIsPlaying || val trackChanged = desiredIdx != current.queueIndex && desiredIdx in queueRefs.indices
val somethingChanged = trackChanged ||
current.isPlaying != effectiveIsPlaying ||
current.positionMs != newPos || current.positionMs != newPos ||
current.durationMs != newDur current.durationMs != newDur
if (changed) { if (!somethingChanged) return
val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack
val newIdx = if (trackChanged) desiredIdx else current.queueIndex
uiStateInternal.value = current.copy( uiStateInternal.value = current.copy(
currentTrack = newTrack,
queueIndex = newIdx,
isPlaying = effectiveIsPlaying, isPlaying = effectiveIsPlaying,
positionMs = newPos, positionMs = newPos,
durationMs = newDur, durationMs = newDur,
bufferedPositionMs = newBuf, 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 ────────────────────────────── // ── Remote position interpolation state ──────────────────────────────
@@ -547,12 +566,6 @@ class PlayerController @Inject constructor(
@Volatile private var positionAnchorMs: Long = 0L @Volatile private var positionAnchorMs: Long = 0L
@Volatile private var positionAnchorAtRealtimeMs: 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 { private fun interpolatedRemotePosition(isPlaying: Boolean): Long {
val raw = remoteState.positionMs val raw = remoteState.positionMs
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()