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() }