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
|
package com.fabledsword.minstrel.player
|
||||||
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
|
import android.os.SystemClock
|
||||||
import androidx.media3.common.ForwardingPlayer
|
import androidx.media3.common.ForwardingPlayer
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||||
@@ -59,6 +60,13 @@ class MinstrelForwardingPlayer(
|
|||||||
// bypasses the poll entirely via applyTransportPaused().
|
// bypasses the poll entirely via applyTransportPaused().
|
||||||
@Volatile private var nonPlayingPollStreak = 0
|
@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 {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
holder.active.collect { active -> onActiveChanged(active) }
|
holder.active.collect { active -> onActiveChanged(active) }
|
||||||
@@ -122,6 +130,7 @@ class MinstrelForwardingPlayer(
|
|||||||
if (active == null) {
|
if (active == null) {
|
||||||
super.seekTo(positionMs)
|
super.seekTo(positionMs)
|
||||||
} else {
|
} else {
|
||||||
|
lastSeekIssuedAtMs = SystemClock.elapsedRealtime()
|
||||||
remoteState.applyPositionInfo(
|
remoteState.applyPositionInfo(
|
||||||
positionMs = positionMs,
|
positionMs = positionMs,
|
||||||
durationMs = remoteState.durationMs,
|
durationMs = remoteState.durationMs,
|
||||||
@@ -288,8 +297,14 @@ class MinstrelForwardingPlayer(
|
|||||||
*/
|
*/
|
||||||
private suspend fun pollOnce(active: ActiveUpnp) {
|
private suspend fun pollOnce(active: ActiveUpnp) {
|
||||||
val info = active.avTransport.getPositionInfo()
|
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(
|
remoteState.applyPositionInfo(
|
||||||
positionMs = info.relTimeMs,
|
positionMs = if (inSeekAckWindow) remoteState.positionMs else info.relTimeMs,
|
||||||
durationMs = info.trackDurationMs,
|
durationMs = info.trackDurationMs,
|
||||||
trackUri = info.trackUri,
|
trackUri = info.trackUri,
|
||||||
trackNumber = info.track,
|
trackNumber = info.track,
|
||||||
@@ -336,5 +351,6 @@ class MinstrelForwardingPlayer(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val POLL_INTERVAL_MS = 1_000L
|
const val POLL_INTERVAL_MS = 1_000L
|
||||||
const val NON_PLAYING_CONFIRM = 2
|
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.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.os.SystemClock
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
@@ -491,16 +492,14 @@ 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)
|
||||||
// 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 upnpActive = activeUpnpHolder.active.value != null
|
||||||
val effectiveIsPlaying =
|
val effectiveIsPlaying =
|
||||||
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
||||||
val effectivePosition =
|
val effectivePosition = if (upnpActive) {
|
||||||
if (upnpActive) remoteState.positionMs else controller.currentPosition
|
interpolatedRemotePosition(effectiveIsPlaying)
|
||||||
|
} else {
|
||||||
|
controller.currentPosition
|
||||||
|
}
|
||||||
val current = uiStateInternal.value
|
val current = uiStateInternal.value
|
||||||
val newPos = effectivePosition.coerceAtLeast(0)
|
val newPos = effectivePosition.coerceAtLeast(0)
|
||||||
val newDur = effectiveDuration(
|
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()`
|
* Bridges Media3's `ListenableFuture<MediaController>.buildAsync()`
|
||||||
* to a suspend function without pulling in `kotlinx-coroutines-guava`
|
* to a suspend function without pulling in `kotlinx-coroutines-guava`
|
||||||
|
|||||||
Reference in New Issue
Block a user