From f031b186babcad02f9eff36bf2def32be2fe3ab7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 11:23:36 -0400 Subject: [PATCH] feat(android): PlayerFactory + CacheConfig + Media3 1.10.1 bump (M8 phase 6.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First Media3 wiring. PlayerFactory builds the process-singleton ExoPlayer with our shared OkHttp + SimpleCache chain; the MinstrelPlayerService (Phase 6.2) calls build() in onCreate. Chain shape: ExoPlayer .setMediaSourceFactory(DefaultMediaSourceFactory + CacheDataSource) .setAudioAttributes(USAGE_MEDIA + CONTENT_TYPE_MUSIC, focus=true) .setHandleAudioBecomingNoisy(true) CacheDataSource .setCache(SimpleCache(audio_cache dir, LRU evictor, Room standalone DB)) .setUpstreamDataSourceFactory(OkHttpDataSource over shared OkHttp) .setCacheWriteDataSinkFactory(CacheDataSink full-fragment) Built-in audio focus + becoming-noisy handling — Media3 owns these so no audio_session-equivalent code path is needed (the Flutter app had ~40 LOC for the same; here it's three lines of config). simpleCache exposed as a PlayerFactory val so the AudioCacheEviction Worker (Phase 12.3) can call removeSpan() during 2-bucket eviction. CacheConfig (defaults 200MiB liked + 150MiB rolling) — Phase 11 Settings will let users override. Bumped Media3 1.4.1 -> 1.10.1 (current stable, AGP 9 + Kotlin 2.3 friendly, MediaSessionService now extends LifecycleService which makes 6.2's lifecycle-aware patterns cleaner). Breaking changes between 1.4 and 1.10 don't affect our usage (DRM, FrameExtractor, ChannelMixingMatrix — we use none). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/cache/audio_cache/CacheConfig.kt | 23 ++++++ .../minstrel/player/PlayerFactory.kt | 74 +++++++++++++++++++ .../minstrel/player/PlayerModule.kt | 27 +++++++ android/gradle/libs.versions.toml | 2 +- 4 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/audio_cache/CacheConfig.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt 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"