From 8df41c3bed5bc24559a6fd8ce62e05c3061486dc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 19:02:27 -0400 Subject: [PATCH] feat(android): AudioPrefetcher pre-downloads next-N tracks into SimpleCache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CacheSettings.prefetchWindow has shipped at default=5 since M8 but the prefetcher itself was deferred to a follow-up. Without it, forward skips re-fetch from the network every time and gapless transitions stall. New AudioPrefetcher singleton subscribes to PlayerController.uiState + AuthStore.cacheSettings, walks queue[currentIndex+1 .. +window], and runs each missing track through Media3's CacheWriter against the same SimpleCache the player reads from (PlayerFactory.simpleCache). DataSpec uses setKey(trackId) to match PlayerController.toMediaItem's setCustomCacheKey(id) — without this the player would miss the cached bytes on read-through. Reconcile is idempotent: CacheWriter is a no-op when bytes are already resident, so distinctUntilChanged on (queue, index, window) gates re-runs and the per-item check is cheap. Window slides cancel in-flight jobs for tracks that have dropped out (skip-prev, queue rebuild) so a stale prefetch doesn't keep the network busy. Eager-constructed via the construct-the-singleton trick in MinstrelApplication, alongside CacheIndexer and CoverPrefetcher. Mirrors flutter_client/lib/cache/prefetcher.dart's user-visible behavior (queue-walk + per-track pin + idempotent reconcile) implemented with the native Media3 primitives (CacheWriter + SimpleCache) instead of Flutter's AudioCacheManager.pin. --- .../minstrel/MinstrelApplication.kt | 10 ++ .../minstrel/player/AudioPrefetcher.kt | 144 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index 522f6e84..9aa83efe 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -13,6 +13,7 @@ import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.LiveEventsDispatcher import com.fabledsword.minstrel.metadata.FreshnessSweeper +import com.fabledsword.minstrel.player.AudioPrefetcher import com.fabledsword.minstrel.player.CoverPrefetcher import com.fabledsword.minstrel.player.PlayEventsReporter import com.fabledsword.minstrel.player.PlaybackErrorReporter @@ -103,6 +104,15 @@ class MinstrelApplication : */ @Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer + /** + * Same construct-the-singleton trick — AudioPrefetcher's init + * block subscribes to PlayerController.uiState + + * AuthStore.cacheSettings and pins the next-N tracks into + * SimpleCache via CacheWriter. Without this @Inject the + * prefetchWindow setting would be inert and skips would re-fetch. + */ + @Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher + /** * Same construct-the-singleton trick — VersionCheckController's * init block starts a 5-min poll loop against /healthz so the 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 new file mode 100644 index 00000000..bbd62a65 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt @@ -0,0 +1,144 @@ +package com.fabledsword.minstrel.player + +import android.net.Uri +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.CacheWriter +import androidx.media3.datasource.okhttp.OkHttpDataSource +import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.shared.resolveServerUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.OkHttpClient +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Pre-downloads the next-N tracks in the queue into the shared Media3 + * [androidx.media3.datasource.cache.SimpleCache] so a skip-forward or + * natural advance plays from disk instead of waiting on a fresh HTTP + * connection. Mirrors the Flutter `Prefetcher` (cache/prefetcher.dart): + * watches the player's current track, walks forward by + * [com.fabledsword.minstrel.cache.audiocache.CacheSettings.prefetchWindow] + * tracks, and pins each one. Idempotent — `CacheWriter` is a no-op when + * a track is already fully resident, so the reconcile is cheap on + * every queue/index change. + * + * Eager-constructed via the construct-the-singleton trick in + * [com.fabledsword.minstrel.MinstrelApplication]; the init block wires + * the observer. + */ +@Singleton +class AudioPrefetcher @Inject constructor( + @ApplicationScope private val scope: CoroutineScope, + private val playerController: PlayerController, + private val playerFactory: PlayerFactory, + private val authStore: AuthStore, + private val okHttpClient: OkHttpClient, +) { + // Reuse the same cache + upstream chain the player uses. Without the + // SimpleCache reference here the prefetcher would write to a + // disjoint store and playback would never read what we pinned. + private val cacheDataSourceFactory: CacheDataSource.Factory = + CacheDataSource.Factory() + .setCache(playerFactory.simpleCache) + .setUpstreamDataSourceFactory(OkHttpDataSource.Factory(okHttpClient)) + + // trackId -> in-flight job. Reconcile cancels jobs whose track has + // dropped out of the window (skip-prev, queue rebuild) so a stale + // prefetch doesn't keep the network busy. + private val activeJobs = mutableMapOf() + private val mutex = Mutex() + + 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) } + .distinctUntilChanged() + .collect { (queue, index, window) -> reconcile(queue, index, window) } + } + } + + private suspend fun reconcile( + queue: List>, + index: Int, + window: Int, + ) { + mutex.withLock { + if (index < 0 || queue.isEmpty() || window <= 0) { + 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 } + + // 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) + } + + // 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) continue + if (streamUrl.isBlank()) continue + val job = scope.launch(Dispatchers.IO) { + runCatching { prefetchOne(trackId, streamUrl) } + mutex.withLock { activeJobs.remove(trackId) } + } + activeJobs[trackId] = job + } + } + } + + private fun cancelAllLocked() { + activeJobs.values.forEach { it.cancel() } + activeJobs.clear() + } + + private fun prefetchOne(trackId: String, streamUrl: String) { + // Match PlayerController.toMediaItem's resolveServerUrl + + // setCustomCacheKey(id) so the cached bytes are keyed exactly + // like a normal playback request — otherwise the player would + // miss them on read-through. + val resolved = resolveServerUrl(streamUrl) ?: streamUrl + val dataSpec = DataSpec.Builder() + .setUri(Uri.parse(resolved)) + .setKey(trackId) + .build() + val writer = CacheWriter( + cacheDataSourceFactory.createDataSource(), + dataSpec, + /* temporaryBuffer = */ null, + /* progressListener = */ null, + ) + // Blocking, but we're on Dispatchers.IO. Throws on network + // failure — outer runCatching swallows so a single failed + // prefetch doesn't trip the whole reconcile. + writer.cache() + } +}