From 2f4d67d3c8736f5cc26b7563c1464d4e577efefc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:34:35 -0400 Subject: [PATCH] feat(android): PlayerFactory wraps ExoPlayer in ForwardingPlayer + drop events Co-Authored-By: Claude Sonnet 4.6 --- .../minstrel/player/PlayerFactory.kt | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) 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 index 8bfe3ccb..e7222217 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt @@ -3,6 +3,7 @@ package com.fabledsword.minstrel.player import android.content.Context import androidx.media3.common.AudioAttributes import androidx.media3.common.C +import androidx.media3.common.Player import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.cache.CacheDataSink 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.source.DefaultMediaSourceFactory import com.fabledsword.minstrel.cache.audiocache.CacheConfig +import com.fabledsword.minstrel.player.output.ActiveUpnpHolder 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 java.io.File import javax.inject.Inject 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 * `build()` once during onCreate. * @@ -31,12 +37,22 @@ import javax.inject.Singleton * (sizeBytes cap = rollingCap); our policy layer in the worker layers * the 2-bucket protection on top by feeding `removeSpan` only for * 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 class PlayerFactory @Inject constructor( @ApplicationContext private val context: Context, private val okHttpClient: OkHttpClient, 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() } @@ -46,7 +62,28 @@ class PlayerFactory @Inject constructor( 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( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val dropEvents: SharedFlow = 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 cacheDataSource = CacheDataSource.Factory() .setCache(simpleCache) @@ -71,4 +108,8 @@ class PlayerFactory @Inject constructor( .setHandleAudioBecomingNoisy(true) .build() } + + private fun emitDrop(routeName: String) { + dropEventsInternal.tryEmit(routeName) + } }