fix(android): polling tick owns track-change updates when delegate.seekTo silent
android / Build + lint + test (push) Successful in 3m26s
android / Build + lint + test (push) Successful in 3m26s
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user