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 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user