fix(android): interpolate UPnP position between polls + suppress post-seek race
android / Build + lint + test (push) Successful in 4m5s
android / Build + lint + test (push) Successful in 4m5s
This commit is contained in:
+17
-1
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user