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.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<Unit>(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<Unit> {
onTimeout(POLL_INTERVAL_MS) {}
pollTrigger.onReceive {}
}
}
}
@@ -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
}
}