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) {
|
scope.launch(Dispatchers.Main.immediate) {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(POSITION_POLL_INTERVAL_MS)
|
delay(POSITION_POLL_INTERVAL_MS)
|
||||||
val upnpActive = activeUpnpHolder.active.value != null
|
tickPositionPoll(controller)
|
||||||
// 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ──────────────────────────────
|
// ── Remote position interpolation state ──────────────────────────────
|
||||||
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
||||||
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
|
// 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 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()
|
||||||
|
|||||||
Reference in New Issue
Block a user