feat(android): populate audio_cache_index from playback (#38 slice 1)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 12:47:10 -04:00
parent ffc2acc010
commit 7024549e35
3 changed files with 80 additions and 0 deletions
@@ -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
@@ -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,
),
)
}
}
}
@@ -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()
}