From 7024549e35f77617c6547b64c3a93c53b2501375 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 12:47:10 -0400 Subject: [PATCH] feat(android): populate audio_cache_index from playback (#38 slice 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CacheIndexer observes PlayerController.uiState and records each played track into audio_cache_index (source=INCIDENTAL), bumping lastPlayedAt on re-play. This gives the offline ShuffleSource pools their data source — previously the index was never written, so the pools were inert. MediaItems now set a custom cache key of trackId so Media3's SimpleCache is keyed per track (sets up slice 2's residency queries). Intentional divergence from Flutter's file-per-track index: SimpleCache (span-based) is the real byte store, so the index is a lightweight play-recency record. Slice 2 (#33) queries SimpleCache for true residency + size + eviction-sync to drive the cached indicator dot. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/MinstrelApplication.kt | 10 +++ .../minstrel/cache/CacheIndexer.kt | 66 +++++++++++++++++++ .../minstrel/player/PlayerController.kt | 4 ++ 3 files changed, 80 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/CacheIndexer.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 0b3b79d8..bf7cf59c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -6,6 +6,7 @@ import androidx.work.Configuration import coil3.ImageLoader import coil3.SingletonImageLoader import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import com.fabledsword.minstrel.cache.CacheIndexer import com.fabledsword.minstrel.cache.mutations.MutationReplayer import com.fabledsword.minstrel.cache.sync.SyncController import com.fabledsword.minstrel.di.ApplicationScope @@ -92,6 +93,15 @@ class MinstrelApplication : */ @Suppress("unused") @Inject lateinit var coverPrefetcher: CoverPrefetcher + /** + * Same construct-the-singleton trick — CacheIndexer's init block + * subscribes to PlayerController.uiState and records each played + * track into `audio_cache_index`, giving the offline shuffle pools + * and the cached indicator their data source. Without this @Inject + * the index would stay empty and the offline pools would be inert. + */ + @Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer + /** * 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/cache/CacheIndexer.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/CacheIndexer.kt new file mode 100644 index 00000000..f8168f2a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/CacheIndexer.kt @@ -0,0 +1,66 @@ +package com.fabledsword.minstrel.cache + +import com.fabledsword.minstrel.cache.db.CacheSource +import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao +import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.player.PlayerController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Populates `audio_cache_index` from playback so the offline pools + * ([ShuffleSource]) and the cached indicator have a data source. + * + * Mirrors the user-visible behavior of Flutter's `AudioCacheManager` + * (`registerStreamCache` + `touch`): a track you play gets cached as a + * side effect of streaming, and playing it bumps its recency. The + * Android idiom differs intentionally — Media3's `SimpleCache` is the + * real byte store (span-based, keyed by trackId via the MediaItem's + * custom cache key), so this index is a lightweight *play-recency* + * record (source = INCIDENTAL) rather than Flutter's file-per-track + * table. SimpleCache stays the source of truth for "is it actually on + * disk"; this index orders "recently played" for the offline pools. + * + * State observed against [PlayerController.uiState], deduped by track + * id so each entry into the player records once. Eager-constructed via + * the construct-the-singleton trick in `MinstrelApplication`. + */ +@Singleton +class CacheIndexer @Inject constructor( + playerController: PlayerController, + private val dao: AudioCacheIndexDao, + @ApplicationScope private val scope: CoroutineScope, +) { + init { + scope.launch { + playerController.uiState + .map { it.currentTrack } + .distinctUntilChanged { a, b -> a?.id == b?.id } + .collect { track -> if (track != null) record(track) } + } + } + + private suspend fun record(track: TrackRef) { + val now = Clock.System.now() + if (dao.get(track.id) != null) { + dao.touchLastPlayed(track.id, now) + } else { + dao.upsert( + AudioCacheIndexEntity( + trackId = track.id, + path = track.streamUrl, + sizeBytes = 0, + lastPlayedAt = now, + source = CacheSource.INCIDENTAL, + ), + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index 2370f92a..2e9504b7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -275,6 +275,10 @@ class PlayerController @Inject constructor( return MediaItem.Builder() .setMediaId(id) .setUri(streamUrl) + // Key the Media3 SimpleCache by trackId (not the stream URL) so + // cache residency can be queried per track for the cached dot + + // offline pools, independent of URL/host rewrites. + .setCustomCacheKey(id) .setMediaMetadata(metadata) .build() }