fix(android): UPnP cast resilience — drop-suppression, session adopt, recovery hardening #98
+35
-3
@@ -10,6 +10,8 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
|||||||
import androidx.media3.common.ForwardingPlayer
|
import androidx.media3.common.ForwardingPlayer
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
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.ActiveUpnp
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
@@ -64,6 +66,7 @@ class MinstrelForwardingPlayer(
|
|||||||
private val holder: ActiveUpnpHolder,
|
private val holder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
private val castNetworkLock: CastNetworkLock,
|
private val castNetworkLock: CastNetworkLock,
|
||||||
|
private val networkStatus: NetworkStatusController,
|
||||||
private val onDrop: (routeName: String) -> Unit,
|
private val onDrop: (routeName: String) -> Unit,
|
||||||
) : ForwardingPlayer(delegate) {
|
) : ForwardingPlayer(delegate) {
|
||||||
|
|
||||||
@@ -460,14 +463,19 @@ class MinstrelForwardingPlayer(
|
|||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
||||||
private suspend fun pollLoop(active: ActiveUpnp) {
|
private suspend fun pollLoop(active: ActiveUpnp) {
|
||||||
|
var networkDropSuppressed = false
|
||||||
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
||||||
val outcome = runCatching { pollOnce(active) }
|
val outcome = runCatching { pollOnce(active) }
|
||||||
if (outcome.isSuccess) {
|
if (outcome.isSuccess) {
|
||||||
remoteState.recordPollSuccess()
|
remoteState.recordPollSuccess()
|
||||||
|
networkDropSuppressed = false
|
||||||
} else if (remoteState.recordPollFailure()) {
|
} else if (remoteState.recordPollFailure()) {
|
||||||
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
if (networkStatus.state.value == ServerHealth.Healthy) {
|
||||||
handler.post { onDrop(active.routeName) }
|
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
||||||
return
|
handler.post { onDrop(active.routeName) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed)
|
||||||
}
|
}
|
||||||
// Race the normal cadence against any external wake (activity
|
// Race the normal cadence against any external wake (activity
|
||||||
// resume). Whichever wins continues to the next pollOnce.
|
// 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
|
* One poll tick: read position + transport state from Sonos, apply to
|
||||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ class PlayerFactory @Inject constructor(
|
|||||||
holder = activeUpnpHolder,
|
holder = activeUpnpHolder,
|
||||||
remoteState = remoteState,
|
remoteState = remoteState,
|
||||||
castNetworkLock = CastNetworkLock(context),
|
castNetworkLock = CastNetworkLock(context),
|
||||||
|
networkStatus = serverHealth,
|
||||||
onDrop = { name -> emitDrop(name) },
|
onDrop = { name -> emitDrop(name) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user