diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index 3b35f103..8aaedd0e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -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() diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt index 1160653f..da21cd22 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt @@ -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() } /**