diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/audio_cache/CacheConfig.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/audio_cache/CacheConfig.kt new file mode 100644 index 00000000..297bdbb8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/audio_cache/CacheConfig.kt @@ -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 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt new file mode 100644 index 00000000..c95c5873 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt @@ -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() + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt new file mode 100644 index 00000000..9ebebeb9 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt @@ -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() +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 7abb6167..83632dcc 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -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"