From e6bf99f580695249953c0e7c1341e9df5a072412 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 17:06:45 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20CoverPrefetcher=20=E2=80=94=20?= =?UTF-8?q?warm=20Coil=20cache=20with=20next=20track's=20cover=20(audit=20?= =?UTF-8?q?v2=20#17,=20slice=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes audit v2 #17. App-scoped singleton observes PlayerController.uiState, plucks queue[queueIndex + 1].coverUrl, dedups, and fires Coil's enqueue to warm the memory + disk cache. Fire-and-forget — the Disposable is discarded since the cache write happens on the loader's background thread regardless. Wired via the existing construct-the-singleton trick in MinstrelApplication. Track changes now hit a cache (cover snaps in, dominant-color gradient transitions cleanly) instead of the network. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/MinstrelApplication.kt | 9 +++ .../minstrel/player/CoverPrefetcher.kt | 61 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/CoverPrefetcher.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 cea22ff1..c8f4cf08 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -11,6 +11,7 @@ import com.fabledsword.minstrel.cache.sync.SyncController import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.LiveEventsDispatcher +import com.fabledsword.minstrel.player.CoverPrefetcher import com.fabledsword.minstrel.player.PlayEventsReporter import com.fabledsword.minstrel.player.ResumeController import dagger.hilt.android.HiltAndroidApp @@ -70,6 +71,14 @@ class MinstrelApplication : */ @Suppress("unused") @Inject lateinit var playEventsReporter: PlayEventsReporter + /** + * Same construct-the-singleton trick — CoverPrefetcher's init + * block subscribes to PlayerController.uiState and warms Coil's + * cache with the next track's cover so track changes don't flash + * a loading state. + */ + @Suppress("unused") @Inject lateinit var coverPrefetcher: CoverPrefetcher + /** * Same construct-the-singleton trick — EventsStream's init block * subscribes to AuthStore.sessionCookie and opens / closes the diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/CoverPrefetcher.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/CoverPrefetcher.kt new file mode 100644 index 00000000..5b80d260 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/CoverPrefetcher.kt @@ -0,0 +1,61 @@ +package com.fabledsword.minstrel.player + +import android.content.Context +import coil3.SingletonImageLoader +import coil3.request.ImageRequest +import com.fabledsword.minstrel.di.ApplicationScope +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Warms Coil's memory + disk cache with the *next* queued track's + * cover so when the player advances, the on-screen AsyncImage and + * the NowPlaying dominant-color extraction both hit a cache instead + * of waiting on the network. + * + * Pulls the next cover URL out of [PlayerController.uiState] every + * time the queue or current-index changes, deduplicates, and fires + * a Coil enqueue. Fire-and-forget — the request's own Disposable is + * discarded; Coil's cache write happens on the loader's background + * thread regardless of subscription. + * + * Constructed at app launch via the construct-the-singleton trick + * in [com.fabledsword.minstrel.MinstrelApplication]. + */ +@Singleton +class CoverPrefetcher @Inject constructor( + @ApplicationContext private val context: Context, + private val playerController: PlayerController, + @ApplicationScope private val scope: CoroutineScope, +) { + init { + scope.launch { + playerController.uiState + .mapNotNull { it.peekNextCoverUrl() } + .filter { it.isNotEmpty() } + .distinctUntilChanged() + .collect(::prefetch) + } + } + + private fun prefetch(url: String) { + val request = ImageRequest.Builder(context) + .data(url) + .build() + SingletonImageLoader.get(context).enqueue(request) + } +} + +/** + * Cover URL for `queueIndex + 1`, or null when the player is at the + * end of the queue / has no queue. Empty string is preserved so the + * caller can filter it; we don't want to prefetch a placeholder URL. + */ +private fun PlayerUiState.peekNextCoverUrl(): String? = + queue.getOrNull(queueIndex + 1)?.coverUrl