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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user