diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index b4e7a327..c7ed1a98 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -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 } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index ffa3d021..e0760987 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -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.buildAsync()` * to a suspend function without pulling in `kotlinx-coroutines-guava`