From 85cea8d5595bfc1c3158decc281fbcc07e55ca08 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:18:53 -0400 Subject: [PATCH] fix(android): UPnP forwarding -- route mint failures through drop, no double drop Co-Authored-By: Claude Sonnet 4.6 --- .../player/MinstrelForwardingPlayer.kt | 27 ++++++++++++++----- .../minstrel/player/RemotePlayerState.kt | 2 +- .../player/output/OutputPickerController.kt | 2 +- 3 files changed, 22 insertions(+), 9 deletions(-) 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 1f88e18a..543e58a4 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 @@ -101,18 +101,30 @@ class MinstrelForwardingPlayer( override fun seekToNextMediaItem() { val active = holder.active.value - super.seekToNextMediaItem() - if (active != null) { - scope.launch { syncCurrentItemToRemote(active) } + if (active == null) { + super.seekToNextMediaItem() + return } + // Advance the local cursor first so syncCurrentItemToRemote can read + // the new currentMediaItem on the application looper. ExoPlayer stays + // paused while UPnP is active (playWhenReady=false invariant), so this + // does not produce local audio. + super.seekToNextMediaItem() + scope.launch { syncCurrentItemToRemote(active) } } override fun seekToPreviousMediaItem() { val active = holder.active.value - super.seekToPreviousMediaItem() - if (active != null) { - scope.launch { syncCurrentItemToRemote(active) } + if (active == null) { + super.seekToPreviousMediaItem() + return } + // Advance the local cursor first so syncCurrentItemToRemote can read + // the new currentMediaItem on the application looper. ExoPlayer stays + // paused while UPnP is active (playWhenReady=false invariant), so this + // does not produce local audio. + super.seekToPreviousMediaItem() + scope.launch { syncCurrentItemToRemote(active) } } override fun getCurrentPosition(): Long = @@ -135,8 +147,8 @@ class MinstrelForwardingPlayer( private suspend fun syncCurrentItemToRemote(active: ActiveUpnp) { val mediaId = handlerEvaluate { delegate.currentMediaItem?.mediaId } ?: return - val token = runCatching { tokens.mint(mediaId) }.getOrNull() ?: return runCatching { + val token = tokens.mint(mediaId) active.avTransport.setAVTransportURIWithMetadata( uri = token.url, mime = token.mime, @@ -148,6 +160,7 @@ class MinstrelForwardingPlayer( } private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) { + pollJob?.cancel() Timber.w(t, "UPnP transport call failed on %s", active.routeName) remoteState.applyError(t) handler.post { onDrop(active.routeName) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt index 3745e3b9..9f43f444 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt @@ -22,7 +22,7 @@ class RemotePlayerState { @Volatile var currentTrackUri: String = ""; private set @Volatile var lastError: Throwable? = null; private set - private var consecutivePollFailures: Int = 0 + @Volatile private var consecutivePollFailures: Int = 0 fun applyPositionInfo(positionMs: Long, durationMs: Long, trackUri: String) { this.positionMs = positionMs 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 76e64315..e064db0b 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 @@ -45,7 +45,7 @@ data class RouteSnapshot( * - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in, * wired, Bluetooth) * - [OutputRoute.Protocol.UPNP] — mint a signed stream token via - * [CastApi.streamToken], drive the discovered renderer with + * [StreamTokenProvider.mint], drive the discovered renderer with * AVTransport.SetAVTransportURI + Play, pause local playback so * audio yields to the network speaker * - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —