fix(android): interpolate UPnP position between polls + suppress post-seek race
android / Build + lint + test (push) Successful in 4m5s

This commit is contained in:
2026-06-04 00:16:57 -04:00
parent 88b161193d
commit 2425a305eb
2 changed files with 49 additions and 8 deletions
@@ -3,6 +3,7 @@
package com.fabledsword.minstrel.player
import android.os.Handler
import android.os.SystemClock
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.Player
import com.fabledsword.minstrel.player.output.ActiveUpnp
@@ -59,6 +60,13 @@ class MinstrelForwardingPlayer(
// bypasses the poll entirely via applyTransportPaused().
@Volatile private var nonPlayingPollStreak = 0
// Wall-clock of the most-recent within-track seek we issued to Sonos.
// pollOnce uses this to suppress position overwrites for SEEK_ACK_WINDOW_MS
// -- Sonos can take 1-2s to apply a Seek, and a poll landing inside that
// window reports the *old* position. Without the lockout the scrubber
// visibly jumps backwards immediately after a drag, then forwards again.
@Volatile private var lastSeekIssuedAtMs: Long = 0L
init {
scope.launch {
holder.active.collect { active -> onActiveChanged(active) }
@@ -122,6 +130,7 @@ class MinstrelForwardingPlayer(
if (active == null) {
super.seekTo(positionMs)
} else {
lastSeekIssuedAtMs = SystemClock.elapsedRealtime()
remoteState.applyPositionInfo(
positionMs = positionMs,
durationMs = remoteState.durationMs,
@@ -288,8 +297,14 @@ class MinstrelForwardingPlayer(
*/
private suspend fun pollOnce(active: ActiveUpnp) {
val info = active.avTransport.getPositionInfo()
val now = SystemClock.elapsedRealtime()
val inSeekAckWindow = lastSeekIssuedAtMs > 0L &&
(now - lastSeekIssuedAtMs) < SEEK_ACK_WINDOW_MS
// Inside the seek-ack window, keep the optimistic position we wrote in
// seekTo -- the poll's reported position is stale until Sonos finishes
// processing the Seek SOAP. Other fields still refresh from the poll.
remoteState.applyPositionInfo(
positionMs = info.relTimeMs,
positionMs = if (inSeekAckWindow) remoteState.positionMs else info.relTimeMs,
durationMs = info.trackDurationMs,
trackUri = info.trackUri,
trackNumber = info.track,
@@ -336,5 +351,6 @@ class MinstrelForwardingPlayer(
private companion object {
const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2
const val SEEK_ACK_WINDOW_MS = 2_000L
}
}
@@ -5,6 +5,7 @@ import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
@@ -491,16 +492,14 @@ class PlayerController @Inject constructor(
scope.launch(Dispatchers.Main.immediate) {
while (isActive) {
delay(POSITION_POLL_INTERVAL_MS)
// When UPnP is active, MediaController's cached state is stale --
// remoteState.applyTransportPlaying() doesn't fire a Player.Listener
// event, so onEvents never refreshes isPlaying / positionMs from
// the wrapped player. Read directly from remoteState here so the
// play/pause toggle and scrubber reflect Sonos's actual state.
val upnpActive = activeUpnpHolder.active.value != null
val effectiveIsPlaying =
if (upnpActive) remoteState.isPlaying else controller.isPlaying
val effectivePosition =
if (upnpActive) remoteState.positionMs else controller.currentPosition
val effectivePosition = if (upnpActive) {
interpolatedRemotePosition(effectiveIsPlaying)
} else {
controller.currentPosition
}
val current = uiStateInternal.value
val newPos = effectivePosition.coerceAtLeast(0)
val newDur = effectiveDuration(
@@ -523,6 +522,32 @@ class PlayerController @Inject constructor(
}
}
// ── 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
// sleep can stall it for many seconds). To keep the scrubber moving
// smoothly we anchor each fresh reading + an elapsed-realtime stamp;
// between updates we display anchor + elapsed when Sonos is playing.
// A real correction lands as soon as the next poll arrives.
@Volatile private var lastSeenRemotePositionMs: Long = -1L
@Volatile private var positionAnchorMs: Long = 0L
@Volatile private var positionAnchorAtRealtimeMs: Long = 0L
private fun interpolatedRemotePosition(isPlaying: Boolean): Long {
val raw = remoteState.positionMs
val now = SystemClock.elapsedRealtime()
if (raw != lastSeenRemotePositionMs) {
lastSeenRemotePositionMs = raw
positionAnchorMs = raw
positionAnchorAtRealtimeMs = now
}
return if (isPlaying) {
positionAnchorMs + (now - positionAnchorAtRealtimeMs)
} else {
positionAnchorMs
}
}
/**
* Bridges Media3's `ListenableFuture<MediaController>.buildAsync()`
* to a suspend function without pulling in `kotlinx-coroutines-guava`