feat(android): PlayerFactory wraps ExoPlayer in ForwardingPlayer + drop events
android / Build + lint + test (push) Failing after 1m35s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 17:34:35 -04:00
parent b2bfe96559
commit 2f4d67d3c8
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.player
import android.content.Context import android.content.Context
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.cache.CacheDataSink import androidx.media3.datasource.cache.CacheDataSink
import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.CacheDataSource
@@ -12,14 +13,19 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import com.fabledsword.minstrel.cache.audiocache.CacheConfig import com.fabledsword.minstrel.cache.audiocache.CacheConfig
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Builds the process-singleton ExoPlayer with our shared OkHttp + * Builds the process-singleton player with our shared OkHttp +
* SimpleCache chain. The MinstrelPlayerService (Phase 6.2) calls * SimpleCache chain. The MinstrelPlayerService (Phase 6.2) calls
* `build()` once during onCreate. * `build()` once during onCreate.
* *
@@ -31,12 +37,22 @@ import javax.inject.Singleton
* (sizeBytes cap = rollingCap); our policy layer in the worker layers * (sizeBytes cap = rollingCap); our policy layer in the worker layers
* the 2-bucket protection on top by feeding `removeSpan` only for * the 2-bucket protection on top by feeding `removeSpan` only for
* unprotected tracks. * unprotected tracks.
*
* `build()` returns a [MinstrelForwardingPlayer] wrapping the internal
* ExoPlayer. When the UPnP route drops (3 consecutive poll failures or
* a SOAP failure), [dropEvents] emits the route name so the NowPlaying
* surface can show a snackbar. The MutableSharedFlow uses DROP_OLDEST
* with capacity=1 so a burst of failures during a single tear-down
* surfaces as one event rather than queueing N.
*/ */
@Singleton @Singleton
class PlayerFactory @Inject constructor( class PlayerFactory @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val cacheConfig: CacheConfig, private val cacheConfig: CacheConfig,
private val activeUpnpHolder: ActiveUpnpHolder,
private val streamTokens: StreamTokenProvider,
private val remoteState: RemotePlayerState,
) { ) {
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() } private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
@@ -46,7 +62,28 @@ class PlayerFactory @Inject constructor(
StandaloneDatabaseProvider(context), StandaloneDatabaseProvider(context),
) )
fun build(): ExoPlayer { // MutableSharedFlow with extraBufferCapacity=1 + DROP_OLDEST so a burst
// of drop events (rapid SOAP failures during a single tear-down) surfaces
// as one snackbar rather than queueing N.
private val dropEventsInternal = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val dropEvents: SharedFlow<String> = dropEventsInternal.asSharedFlow()
fun build(): Player {
val exo = buildExoPlayer()
return MinstrelForwardingPlayer(
delegate = exo,
holder = activeUpnpHolder,
remoteState = remoteState,
tokens = streamTokens,
onDrop = { name -> emitDrop(name) },
)
}
private fun buildExoPlayer(): ExoPlayer {
val httpDataSource = OkHttpDataSource.Factory(okHttpClient) val httpDataSource = OkHttpDataSource.Factory(okHttpClient)
val cacheDataSource = CacheDataSource.Factory() val cacheDataSource = CacheDataSource.Factory()
.setCache(simpleCache) .setCache(simpleCache)
@@ -71,4 +108,8 @@ class PlayerFactory @Inject constructor(
.setHandleAudioBecomingNoisy(true) .setHandleAudioBecomingNoisy(true)
.build() .build()
} }
private fun emitDrop(routeName: String) {
dropEventsInternal.tryEmit(routeName)
}
} }