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 c7ed1a98..60525efd 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 @@ -4,6 +4,9 @@ package com.fabledsword.minstrel.player import android.os.Handler import android.os.SystemClock +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner import androidx.media3.common.ForwardingPlayer import androidx.media3.common.Player import com.fabledsword.minstrel.player.output.ActiveUpnp @@ -14,9 +17,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.onTimeout +import kotlinx.coroutines.selects.select import timber.log.Timber /** @@ -67,10 +73,29 @@ class MinstrelForwardingPlayer( // visibly jumps backwards immediately after a drag, then forwards again. @Volatile private var lastSeekIssuedAtMs: Long = 0L + // Wake channel for the poll loop. requestImmediatePoll() trySend's a Unit; + // pollLoop's select{} races the delay against this channel so the next + // pollOnce can fire immediately instead of waiting up to POLL_INTERVAL_MS. + // Used on activity resume (ProcessLifecycleOwner.ON_RESUME) so the UI + // catches up to Sonos within RTT rather than the full poll cadence. + // CONFLATED so repeated trySend's between polls don't queue up. + private val pollTrigger = Channel(Channel.CONFLATED) + + private val lifecycleObserver = object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + pollTrigger.trySend(Unit) + } + } + init { scope.launch { holder.active.collect { active -> onActiveChanged(active) } } + // Process lifecycle is observed on the main thread; ProcessLifecycleOwner's + // addObserver requires it. The observer just trySend's to the channel. + handler.post { + ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver) + } } private fun isRemote(): Boolean = holder.active.value != null @@ -238,6 +263,9 @@ class MinstrelForwardingPlayer( override fun release() { pollJob?.cancel() scope.cancel() + handler.post { + ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver) + } super.release() } @@ -266,6 +294,7 @@ class MinstrelForwardingPlayer( } } + @OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive private suspend fun pollLoop(active: ActiveUpnp) { while (scope.isActive && holder.active.value?.routeId == active.routeId) { val outcome = runCatching { pollOnce(active) } @@ -276,7 +305,12 @@ class MinstrelForwardingPlayer( handler.post { onDrop(active.routeName) } return } - delay(POLL_INTERVAL_MS) + // Race the normal cadence against any external wake (activity + // resume). Whichever wins continues to the next pollOnce. + select { + onTimeout(POLL_INTERVAL_MS) {} + pollTrigger.onReceive {} + } } } 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 74cc2dc2..f9ee7ed1 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 @@ -561,11 +561,13 @@ class PlayerController @Inject constructor( positionAnchorMs = raw positionAnchorAtRealtimeMs = now } - return if (isPlaying) { - positionAnchorMs + (now - positionAnchorAtRealtimeMs) - } else { - positionAnchorMs - } + if (!isPlaying) return positionAnchorMs + // Cap how far past the last anchor we extrapolate. After + // MAX_INTERPOLATION_DRIFT_MS without a poll update, freeze the + // displayed position at anchor + cap rather than projecting wildly. + // The next successful poll re-anchors and motion resumes. + val delta = (now - positionAnchorAtRealtimeMs).coerceAtMost(MAX_INTERPOLATION_DRIFT_MS) + return positionAnchorMs + delta } /** @@ -647,6 +649,7 @@ class PlayerController @Inject constructor( companion object { const val MINSTREL_SOURCE_KEY: String = "minstrel_source" private const val MS_PER_SECOND = 1_000L + private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L } }