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:
+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