fix(android): force poll on ON_RESUME + cap interpolation drift at 5s
android / Build + lint + test (push) Has been cancelled

This commit is contained in:
2026-06-04 00:55:10 -04:00
parent 41230b5afb
commit 389c896d65
2 changed files with 44 additions and 7 deletions
@@ -4,6 +4,9 @@ package com.fabledsword.minstrel.player
import android.os.Handler import android.os.Handler
import android.os.SystemClock 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.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
@@ -14,9 +17,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.onTimeout
import kotlinx.coroutines.selects.select
import timber.log.Timber import timber.log.Timber
/** /**
@@ -67,10 +73,29 @@ class MinstrelForwardingPlayer(
// visibly jumps backwards immediately after a drag, then forwards again. // visibly jumps backwards immediately after a drag, then forwards again.
@Volatile private var lastSeekIssuedAtMs: Long = 0L @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<Unit>(Channel.CONFLATED)
private val lifecycleObserver = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
pollTrigger.trySend(Unit)
}
}
init { init {
scope.launch { scope.launch {
holder.active.collect { active -> onActiveChanged(active) } 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 private fun isRemote(): Boolean = holder.active.value != null
@@ -238,6 +263,9 @@ class MinstrelForwardingPlayer(
override fun release() { override fun release() {
pollJob?.cancel() pollJob?.cancel()
scope.cancel() scope.cancel()
handler.post {
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
}
super.release() super.release()
} }
@@ -266,6 +294,7 @@ class MinstrelForwardingPlayer(
} }
} }
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
private suspend fun pollLoop(active: ActiveUpnp) { private suspend fun pollLoop(active: ActiveUpnp) {
while (scope.isActive && holder.active.value?.routeId == active.routeId) { while (scope.isActive && holder.active.value?.routeId == active.routeId) {
val outcome = runCatching { pollOnce(active) } val outcome = runCatching { pollOnce(active) }
@@ -276,7 +305,12 @@ class MinstrelForwardingPlayer(
handler.post { onDrop(active.routeName) } handler.post { onDrop(active.routeName) }
return return
} }
delay(POLL_INTERVAL_MS) // Race the normal cadence against any external wake (activity
// resume). Whichever wins continues to the next pollOnce.
select<Unit> {
onTimeout(POLL_INTERVAL_MS) {}
pollTrigger.onReceive {}
}
} }
} }
@@ -561,11 +561,13 @@ class PlayerController @Inject constructor(
positionAnchorMs = raw positionAnchorMs = raw
positionAnchorAtRealtimeMs = now positionAnchorAtRealtimeMs = now
} }
return if (isPlaying) { if (!isPlaying) return positionAnchorMs
positionAnchorMs + (now - positionAnchorAtRealtimeMs) // Cap how far past the last anchor we extrapolate. After
} else { // MAX_INTERPOLATION_DRIFT_MS without a poll update, freeze the
positionAnchorMs // 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 { companion object {
const val MINSTREL_SOURCE_KEY: String = "minstrel_source" const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
private const val MS_PER_SECOND = 1_000L private const val MS_PER_SECOND = 1_000L
private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L
} }
} }