feat: M3 weighted shuffle v1 — real /api/radio with scoring #21
+23
@@ -0,0 +1,23 @@
|
||||
package com.fabledsword.minstrel.cache.audio_cache
|
||||
|
||||
/**
|
||||
* Defaults for the 2-bucket audio cache. Matches the Flutter client.
|
||||
*
|
||||
* - `likedCapBytes`: cap for the protected bucket — cached files for
|
||||
* tracks the user has liked. Evicted only after the rolling bucket
|
||||
* is fully drained (almost never under normal use).
|
||||
* - `rollingCapBytes`: cap for the unprotected bucket — incidental /
|
||||
* auto-prefetch downloads. LRU within this bucket.
|
||||
*
|
||||
* Phase 11 (Settings) will allow user overrides; the values are kept
|
||||
* generous so first-time installs work well without configuration.
|
||||
*/
|
||||
data class CacheConfig(
|
||||
val likedCapBytes: Long = DEFAULT_LIKED_CAP_BYTES,
|
||||
val rollingCapBytes: Long = DEFAULT_ROLLING_CAP_BYTES,
|
||||
) {
|
||||
companion object {
|
||||
const val DEFAULT_LIKED_CAP_BYTES: Long = 200L * 1024 * 1024 // 200 MiB
|
||||
const val DEFAULT_ROLLING_CAP_BYTES: Long = 150L * 1024 * 1024 // 150 MiB
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.cache.CacheDataSink
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import com.fabledsword.minstrel.cache.audio_cache.CacheConfig
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Builds the process-singleton ExoPlayer with our shared OkHttp +
|
||||
* SimpleCache chain. The MinstrelPlayerService (Phase 6.2) calls
|
||||
* `build()` once during onCreate.
|
||||
*
|
||||
* `simpleCache` is exposed as a val so the AudioCacheEvictionWorker
|
||||
* (Phase 12.3) can call `cache.removeSpan(...)` when evicting tracks
|
||||
* to disk-clear their cached spans.
|
||||
*
|
||||
* The default `LeastRecentlyUsedCacheEvictor` provides the floor LRU
|
||||
* (sizeBytes cap = rollingCap); our policy layer in the worker layers
|
||||
* the 2-bucket protection on top by feeding `removeSpan` only for
|
||||
* unprotected tracks.
|
||||
*/
|
||||
@Singleton
|
||||
class PlayerFactory @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val cacheConfig: CacheConfig,
|
||||
) {
|
||||
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
||||
|
||||
val simpleCache: SimpleCache = SimpleCache(
|
||||
cacheDir,
|
||||
LeastRecentlyUsedCacheEvictor(cacheConfig.rollingCapBytes),
|
||||
StandaloneDatabaseProvider(context),
|
||||
)
|
||||
|
||||
fun build(): ExoPlayer {
|
||||
val httpDataSource = OkHttpDataSource.Factory(okHttpClient)
|
||||
val cacheDataSource = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(httpDataSource)
|
||||
.setCacheWriteDataSinkFactory(
|
||||
CacheDataSink.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setFragmentSize(C.LENGTH_UNSET.toLong()),
|
||||
)
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(context)
|
||||
.setDataSourceFactory(cacheDataSource)
|
||||
|
||||
return ExoPlayer.Builder(context)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.build(),
|
||||
/* handleAudioFocus = */ true,
|
||||
)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import com.fabledsword.minstrel.cache.audio_cache.CacheConfig
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Player + audio-cache wiring. PlayerFactory has @Inject so it doesn't
|
||||
* need a @Provides here; this module exists to provide the
|
||||
* configuration values (CacheConfig) it depends on, and any future
|
||||
* player-related singletons that aren't @Inject-constructible.
|
||||
*
|
||||
* MinstrelPlayerService (Phase 6.2) injects PlayerFactory and calls
|
||||
* `build()` from `onCreate`. The eviction worker (Phase 12.3) injects
|
||||
* PlayerFactory too and reads `simpleCache` from it.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PlayerModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCacheConfig(): CacheConfig = CacheConfig()
|
||||
}
|
||||
@@ -21,7 +21,7 @@ kotlinx-serialization = "1.7.3"
|
||||
kotlinx-coroutines = "1.9.0"
|
||||
kotlinx-datetime = "0.6.1"
|
||||
kotlinx-serialization-converter = "1.0.0"
|
||||
media3 = "1.4.1"
|
||||
media3 = "1.10.1"
|
||||
coil = "3.0.0-rc02"
|
||||
timber = "5.0.1"
|
||||
work = "2.10.0"
|
||||
|
||||
Reference in New Issue
Block a user