fix(android): UPnP cast resilience — drop-suppression, session adopt, recovery hardening #98

Merged
bvandeusen merged 2 commits from dev into main 2026-06-12 21:25:25 -04:00
2 changed files with 36 additions and 3 deletions
Showing only changes of commit d6290a3ef0 - Show all commits
@@ -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) },
)
}