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-coroutines = "1.9.0"
|
||||||
kotlinx-datetime = "0.6.1"
|
kotlinx-datetime = "0.6.1"
|
||||||
kotlinx-serialization-converter = "1.0.0"
|
kotlinx-serialization-converter = "1.0.0"
|
||||||
media3 = "1.4.1"
|
media3 = "1.10.1"
|
||||||
coil = "3.0.0-rc02"
|
coil = "3.0.0-rc02"
|
||||||
timber = "5.0.1"
|
timber = "5.0.1"
|
||||||
work = "2.10.0"
|
work = "2.10.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user