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,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()