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()
|
pollJob?.cancel()
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
pollJob = scope.launch { pollLoop(active) }
|
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 {
|
} else {
|
||||||
remoteState.reset()
|
remoteState.reset()
|
||||||
}
|
}
|
||||||
@@ -215,6 +219,10 @@ class MinstrelForwardingPlayer(
|
|||||||
private suspend fun pollLoop(active: ActiveUpnp) {
|
private suspend fun pollLoop(active: ActiveUpnp) {
|
||||||
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
||||||
val outcome = runCatching {
|
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()
|
val info = active.avTransport.getPositionInfo()
|
||||||
remoteState.applyPositionInfo(
|
remoteState.applyPositionInfo(
|
||||||
positionMs = info.relTimeMs,
|
positionMs = info.relTimeMs,
|
||||||
@@ -228,6 +236,25 @@ class MinstrelForwardingPlayer(
|
|||||||
TransportState.STOPPED -> remoteState.applyTransportStopped()
|
TransportState.STOPPED -> remoteState.applyTransportStopped()
|
||||||
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
|
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) {
|
if (outcome.isSuccess) {
|
||||||
remoteState.recordPollSuccess()
|
remoteState.recordPollSuccess()
|
||||||
|
|||||||
+27
@@ -7,6 +7,7 @@ import androidx.mediarouter.media.MediaRouteSelector
|
|||||||
import androidx.mediarouter.media.MediaRouter
|
import androidx.mediarouter.media.MediaRouter
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
|
import com.fabledsword.minstrel.player.PlayerFactory
|
||||||
import com.fabledsword.minstrel.player.RemotePlayerState
|
import com.fabledsword.minstrel.player.RemotePlayerState
|
||||||
import com.fabledsword.minstrel.player.StreamTokenProvider
|
import com.fabledsword.minstrel.player.StreamTokenProvider
|
||||||
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||||
@@ -69,6 +70,7 @@ class OutputPickerController @Inject constructor(
|
|||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
private val upnpDiscovery: UpnpDiscoveryController,
|
private val upnpDiscovery: UpnpDiscoveryController,
|
||||||
private val playerController: PlayerController,
|
private val playerController: PlayerController,
|
||||||
|
private val playerFactory: PlayerFactory,
|
||||||
private val streamTokens: StreamTokenProvider,
|
private val streamTokens: StreamTokenProvider,
|
||||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
@@ -139,6 +141,31 @@ class OutputPickerController @Inject constructor(
|
|||||||
// without forcing Bluetooth scans. (There is no
|
// without forcing Bluetooth scans. (There is no
|
||||||
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
||||||
mediaRouter.addCallback(selector, callback)
|
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