fix(android): don't drop Sonos (or blast local audio) when the phone's own network is down
android / Build + lint + test (push) Successful in 3m43s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 20:48:40 -04:00
parent 4d5af3599e
commit d6290a3ef0
2 changed files with 36 additions and 3 deletions
@@ -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
@@ -82,6 +82,7 @@ class PlayerFactory @Inject constructor(
holder = activeUpnpHolder,
remoteState = remoteState,
castNetworkLock = CastNetworkLock(context),
networkStatus = serverHealth,
onDrop = { name -> emitDrop(name) },
)
}