fix(android): UPnP drop falls back to local; pollLoop detects natural advance
android / Build + lint + test (push) Failing after 1m10s
android / Build + lint + test (push) Failing after 1m10s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
+27
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user