diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index 52977949..9fe589b5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -10,6 +10,8 @@ import androidx.lifecycle.ProcessLifecycleOwner import androidx.media3.common.ForwardingPlayer import androidx.media3.common.MediaItem import androidx.media3.common.Player +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.ServerHealth import com.fabledsword.minstrel.player.output.ActiveUpnp import com.fabledsword.minstrel.player.output.ActiveUpnpHolder import com.fabledsword.minstrel.player.output.upnp.SoapFaultException @@ -64,6 +66,7 @@ class MinstrelForwardingPlayer( private val holder: ActiveUpnpHolder, private val remoteState: RemotePlayerState, private val castNetworkLock: CastNetworkLock, + private val networkStatus: NetworkStatusController, private val onDrop: (routeName: String) -> Unit, ) : ForwardingPlayer(delegate) { @@ -460,14 +463,19 @@ class MinstrelForwardingPlayer( @OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive private suspend fun pollLoop(active: ActiveUpnp) { + var networkDropSuppressed = false while (scope.isActive && holder.active.value?.routeId == active.routeId) { val outcome = runCatching { pollOnce(active) } if (outcome.isSuccess) { remoteState.recordPollSuccess() + networkDropSuppressed = false } else if (remoteState.recordPollFailure()) { - Timber.w("UPnP drop threshold tripped for %s", active.routeName) - handler.post { onDrop(active.routeName) } - return + if (networkStatus.state.value == ServerHealth.Healthy) { + Timber.w("UPnP drop threshold tripped for %s", active.routeName) + handler.post { onDrop(active.routeName) } + return + } + networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed) } // Race the normal cadence against any external wake (activity // resume). Whichever wins continues to the next pollOnce. @@ -478,6 +486,30 @@ class MinstrelForwardingPlayer( } } + /** + * The poll-failure threshold tripped, but the phone's *own* network is down + * (state is not [ServerHealth.Healthy]) -- so this is "we can't see the + * renderer right now," not "the renderer died." A UPnP renderer streams + * autonomously and is almost certainly still playing; meanwhile a local + * fallback couldn't reach the server either, so dropping would only blast + * local audio out of a pocketed phone while the speaker keeps going. Hold + * the route and keep polling -- a successful poll once the network returns + * reconciles to the renderer's real state. Clear the failure streak so + * recovery re-evaluates the renderer from scratch instead of re-dropping on + * the first post-recovery hiccup. Returns the (latched) suppression flag so + * the rationale logs once per outage, not every tick. + */ + private fun suppressDropForNetwork(active: ActiveUpnp, alreadySuppressed: Boolean): Boolean { + if (!alreadySuppressed) { + Timber.w( + "UPnP drop suppressed for %s -- phone network %s, holding route", + active.routeName, networkStatus.state.value, + ) + } + remoteState.recordPollSuccess() // clear streak; not a renderer failure + return true + } + /** * One poll tick: read position + transport state from Sonos, apply to * [remoteState], and forward-sync the local cursor to Sonos's Track 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 47ecfc39..7c3a2cad 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 @@ -82,6 +82,7 @@ class PlayerFactory @Inject constructor( holder = activeUpnpHolder, remoteState = remoteState, castNetworkLock = CastNetworkLock(context), + networkStatus = serverHealth, onDrop = { name -> emitDrop(name) }, ) }