fix(android): UPnP drop falls back to local; pollLoop detects natural advance
android / Build + lint + test (push) Failing after 1m10s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 18:07:20 -04:00
parent 1ab21d81ca
commit edffdec2b2
2 changed files with 54 additions and 0 deletions
@@ -207,6 +207,10 @@ class MinstrelForwardingPlayer(
pollJob?.cancel()
if (active != null) {
pollJob = scope.launch { pollLoop(active) }
// Pre-queue the next track immediately so Sonos can advance
// gap-free into the second track without waiting for the first
// onMediaItemTransition event (which only fires on user skips).
scope.launch { preQueueNext(active) }
} else {
remoteState.reset()
}
@@ -215,6 +219,10 @@ class MinstrelForwardingPlayer(
private suspend fun pollLoop(active: ActiveUpnp) {
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
val outcome = runCatching {
// Capture before applyPositionInfo so we can detect a URI
// change that Sonos made via the pre-queued SetNextAVTransportURI
// (natural auto-advance without any user skip action).
val previousTrackUri = remoteState.currentTrackUri
val info = active.avTransport.getPositionInfo()
remoteState.applyPositionInfo(
positionMs = info.relTimeMs,
@@ -228,6 +236,25 @@ class MinstrelForwardingPlayer(
TransportState.STOPPED -> remoteState.applyTransportStopped()
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
}
// Detect remote-side natural advance: Sonos auto-advanced
// through SetNextAVTransportURI so the URI changed without
// any local user action. Advance the local queue cursor so
// the onMediaItemTransition listener fires preQueueNext to
// set up the next-next URI on Sonos, keeping the chain going.
// Both guards must be non-blank: previousTrackUri="" on the
// very first poll (before any state arrives) and we must not
// treat that initial empty-to-URI transition as an advance.
if (info.trackUri.isNotBlank() &&
previousTrackUri.isNotBlank() &&
info.trackUri != previousTrackUri
) {
// Run on the application looper. ExoPlayer is paused
// while UPnP is active (playWhenReady=false invariant) so
// the cursor advances without producing local audio. The
// delegate's onMediaItemTransition listener (wired in init)
// then calls preQueueNext to load the next-next track.
handler.post { delegate.seekToNextMediaItem() }
}
}
if (outcome.isSuccess) {
remoteState.recordPollSuccess()
@@ -7,6 +7,7 @@ import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerFactory
import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.StreamTokenProvider
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
@@ -69,6 +70,7 @@ class OutputPickerController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
private val upnpDiscovery: UpnpDiscoveryController,
private val playerController: PlayerController,
private val playerFactory: PlayerFactory,
private val streamTokens: StreamTokenProvider,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
@@ -139,6 +141,31 @@ class OutputPickerController @Inject constructor(
// without forcing Bluetooth scans. (There is no
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
mediaRouter.addCallback(selector, callback)
// When MinstrelForwardingPlayer reports the active UPnP route has
// dropped (3+ consecutive poll failures or a transport SOAP exception),
// clear the UPnP selection state and fall back to local ExoPlayer at
// the last-known remote position. This mirrors selectSystem's disconnect
// path but skips the Stop SOAP since the device is already unreachable.
scope.launch {
playerFactory.dropEvents.collect { handleRemoteDrop() }
}
}
/**
* Called when the active UPnP route drops unexpectedly (poll-failure
* threshold or SOAP exception). Captures the last remote position +
* play state, clears UPnP selection, and resumes local ExoPlayer at
* the same point. Skips the Stop SOAP (device already unreachable).
* The snackbar is handled independently by the NowPlaying surface
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
*/
private fun handleRemoteDrop() {
val capturedPositionMs = remoteState.positionMs
val wasPlayingRemote = remoteState.isPlaying
activeUpnpHolder.set(null)
selectedUpnpRouteIdInternal.value = null
playerController.seekTo(capturedPositionMs)
if (wasPlayingRemote) playerController.play()
}
/**