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 mutex = Mutex()
private data class ReconcileInput(
val queue: List<Pair<String, String>>,
val index: Int,
val window: Int,
val isPlaying: Boolean,
)
init {
scope.launch {
combine(
playerController.uiState.map { it.queue.map { t -> t.id to t.streamUrl } },
playerController.uiState.map { it.queueIndex },
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()
.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(
queue: List<Pair<String, String>>,
index: Int,
window: Int,
isPlaying: Boolean,
) {
mutex.withLock {
if (index < 0 || queue.isEmpty() || window <= 0) {
val targets = computeTargets(queue, index, window)
if (targets.isEmpty()) {
cancelAllLocked()
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 }
// 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.
activeJobs.entries
.filter { it.key !in targetIds }
.toList()
.forEach { (id, job) ->
job.cancel()
activeJobs.remove(id)
}
private fun computeTargets(
queue: List<Pair<String, String>>,
index: Int,
window: Int,
): List<Pair<String, String>> {
// Exclude the currently-playing track (it's loaded by the player
// 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
// 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
private fun cancelOutOfWindowLocked(targetIds: Set<String>) {
activeJobs.entries
.filter { it.key !in targetIds }
.toList()
.forEach { (id, job) ->
job.cancel()
activeJobs.remove(id)
}
}
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
}
}