diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt index 9ad8125e..1ac73695 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt @@ -58,59 +58,98 @@ class AudioPrefetcher @Inject constructor( private val activeJobs = mutableMapOf() private val mutex = Mutex() + private data class ReconcileInput( + val queue: List>, + 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>, 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>, + index: Int, + window: Int, + ): List> { + // 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) { + activeJobs.entries + .filter { it.key !in targetIds } + .toList() + .forEach { (id, job) -> + job.cancel() + activeJobs.remove(id) } + } + + private fun startInWindowLocked(targets: List>) { + // 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 } }