feat: player + search + M2 events foundation #19
@@ -6,6 +6,7 @@ import androidx.work.Configuration
|
|||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import com.fabledsword.minstrel.cache.CacheIndexer
|
||||||
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
||||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
@@ -92,6 +93,15 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var coverPrefetcher: CoverPrefetcher
|
@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
|
* Same construct-the-singleton trick — VersionCheckController's
|
||||||
* init block starts a 5-min poll loop against /healthz so the
|
* 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()
|
return MediaItem.Builder()
|
||||||
.setMediaId(id)
|
.setMediaId(id)
|
||||||
.setUri(streamUrl)
|
.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)
|
.setMediaMetadata(metadata)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user