Sonos queue resync + cold-start prefetcher gate #83

Merged
bvandeusen merged 3 commits from dev into main 2026-06-04 17:36:31 -04:00
Showing only changes of commit 9a31955fa4 - Show all commits
@@ -58,59 +58,98 @@ class AudioPrefetcher @Inject constructor(
private val activeJobs = mutableMapOf<String, Job>() private val activeJobs = mutableMapOf<String, Job>()
private val mutex = Mutex() private val mutex = Mutex()
private data class ReconcileInput(
val queue: List<Pair<String, String>>,
val index: Int,
val window: Int,
val isPlaying: Boolean,
)
init { init {
scope.launch { scope.launch {
combine( combine(
playerController.uiState.map { it.queue.map { t -> t.id to t.streamUrl } }, playerController.uiState.map { it.queue.map { t -> t.id to t.streamUrl } },
playerController.uiState.map { it.queueIndex }, playerController.uiState.map { it.queueIndex },
authStore.cacheSettings.map { it.prefetchWindow }, authStore.cacheSettings.map { it.prefetchWindow },
) { queue, index, window -> Triple(queue, index, window) } playerController.uiState.map { it.isPlaying },
) { queue, index, window, isPlaying ->
ReconcileInput(queue, index, window, isPlaying)
}
.distinctUntilChanged() .distinctUntilChanged()
.collect { (queue, index, window) -> reconcile(queue, index, window) } .collect { input ->
reconcile(input.queue, input.index, input.window, input.isPlaying)
}
} }
} }
/**
* Apply the prefetch window to the current queue.
*
* Cancellation of out-of-window jobs always runs -- a queue mutation
* or skip should free bandwidth from stale prefetches immediately.
* Starting new prefetches is gated on [isPlaying]: until the current
* track is actually playing, every byte of upstream bandwidth should
* land on it, not on upcoming-track prefetches. Without this gate a
* cold start fanned out 4-6 concurrent CacheWriter jobs against the
* same OkHttp client as the playback DataSource and the user waited
* ~25 s for the first audio to start; with the gate the current
* track gets the full pipe to its first STATE_READY, then the
* prefetcher fills in the next window.
*/
private suspend fun reconcile( private suspend fun reconcile(
queue: List<Pair<String, String>>, queue: List<Pair<String, String>>,
index: Int, index: Int,
window: Int, window: Int,
isPlaying: Boolean,
) { ) {
mutex.withLock { mutex.withLock {
if (index < 0 || queue.isEmpty() || window <= 0) { val targets = computeTargets(queue, index, window)
if (targets.isEmpty()) {
cancelAllLocked() cancelAllLocked()
return return
} }
// Exclude the currently-playing track (it's loaded by the
// player itself) and walk `window` tracks forward.
val firstIdx = (index + 1).coerceAtMost(queue.size)
val lastIdx = (index + window).coerceAtMost(queue.size - 1)
if (firstIdx > lastIdx) {
cancelAllLocked()
return
}
val targets = queue.subList(firstIdx, lastIdx + 1)
val targetIds = targets.mapTo(mutableSetOf()) { it.first } val targetIds = targets.mapTo(mutableSetOf()) { it.first }
// Cancellation always runs so a queue mutation or skip frees
// the pipe immediately, even while paused.
cancelOutOfWindowLocked(targetIds)
if (isPlaying) startInWindowLocked(targets)
}
}
// Cancel jobs for tracks that have slid out of the window. private fun computeTargets(
activeJobs.entries queue: List<Pair<String, String>>,
.filter { it.key !in targetIds } index: Int,
.toList() window: Int,
.forEach { (id, job) -> ): List<Pair<String, String>> {
job.cancel() // Exclude the currently-playing track (it's loaded by the player
activeJobs.remove(id) // itself) and walk `window` tracks forward.
} val firstIdx = index + 1
val lastIdx = (index + window).coerceAtMost(queue.size - 1)
val isValid = index >= 0 && queue.isNotEmpty() && window > 0 && firstIdx <= lastIdx
return if (isValid) queue.subList(firstIdx, lastIdx + 1) else emptyList()
}
// Start prefetches for new arrivals. Skip blank URLs (these private fun cancelOutOfWindowLocked(targetIds: Set<String>) {
// come from minimal TrackRefs synthesized from playlist rows activeJobs.entries
// when the upstream track was removed from the library). .filter { it.key !in targetIds }
for ((trackId, streamUrl) in targets) { .toList()
if (trackId in activeJobs || streamUrl.isBlank()) continue .forEach { (id, job) ->
val job = scope.launch(Dispatchers.IO) { job.cancel()
runCatching { prefetchOne(trackId, streamUrl) } activeJobs.remove(id)
mutex.withLock { activeJobs.remove(trackId) }
}
activeJobs[trackId] = job
} }
}
private fun startInWindowLocked(targets: List<Pair<String, String>>) {
// Skip blank URLs (these come from minimal TrackRefs synthesized
// from playlist rows when the upstream track was removed from
// the library).
for ((trackId, streamUrl) in targets) {
if (trackId in activeJobs || streamUrl.isBlank()) continue
val job = scope.launch(Dispatchers.IO) {
runCatching { prefetchOne(trackId, streamUrl) }
mutex.withLock { activeJobs.remove(trackId) }
}
activeJobs[trackId] = job
} }
} }