From d6290a3ef00e4552e4f792abdbe7ec716ad1042b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 12 Jun 2026 20:48:40 -0400 Subject: [PATCH] fix(android): don't drop Sonos (or blast local audio) when the phone's own network is down Observed on device: casting to Sonos on battery + screen locked, a transient ~67s reachability gap (NetworkStatus -> ServerDown while WiFi itself stayed associated) starved the 1 Hz poll past DROP_THRESHOLD. The poll loop then dropped the route and fell back to the local player, which honored the play-intent -- so the phone suddenly started playing the song out loud locally while the Sonos was still happily streaming it. A poll failure during a phone-side network outage means "we can't see the renderer right now," not "the renderer died": a UPnP renderer streams autonomously and keeps playing, and the local player we'd fall back to can't reach the server either. Dropping is strictly worse than waiting. Gate the drop on NetworkStatusController: only drop when the phone's network is Healthy (renderer genuinely unreachable on an otherwise-fine link). While Unstable/ServerDown/Offline, hold the route, keep polling, and clear the failure streak so recovery re-evaluates from scratch rather than re-dropping on the first post-recovery hiccup. The poll reconciles to the renderer's real (advanced) position once the network returns. Complements the CastNetworkLock fix: the lock reduces how often these gaps happen; this stops a gap that does happen from punishing the user. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../player/MinstrelForwardingPlayer.kt | 38 +++++++++++++++++-- .../minstrel/player/PlayerFactory.kt | 1 + 2 files changed, 36 insertions(+), 3 deletions(-) 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) }, ) }