perf(android): gate audio prefetch on isPlaying
android / Build + lint + test (push) Successful in 3m41s
android / Build + lint + test (push) Successful in 3m41s
Cold-start playback on a fresh install was taking ~25 s before any audio played. Logcat showed AudioPrefetcher was kicking off N concurrent CacheWriter jobs the instant setQueue updated uiState -- each prefetch a full upcoming-track download over the same OkHttp client as the current-track DataSource. Five-way bandwidth split plus parallel Coil cover fetches starved the current track until its full file body had streamed through (~12 MB at ~1 MB/s under contention). Now reconcile() observes uiState.isPlaying and starts upcoming-track prefetches only when the current track is actually playing. Cancellation of out-of-window jobs always runs so a queue switch or skip still frees the pipe immediately, even while paused. Cold start should drop from ~25 s -> 5-7 s on the user's network: just the single-stream throughput plus the one-time TLS/DNS tax. Refactored the inline reconcile body into computeTargets / cancelOutOfWindowLocked / startInWindowLocked helpers to keep ReturnCount under the detekt cap.
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user