feat(android): PlayerFactory + CacheConfig + Media3 1.10.1 bump (M8 phase 6.1)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 11:23:36 -04:00
parent 0742b45e3d
commit f031b186ba
4 changed files with 125 additions and 1 deletions
@@ -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()
}
+1 -1
View File
@@ -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"