fix(android): UPnP forwarding -- route mint failures through drop, no double drop
android / Build + lint + test (push) Failing after 1m25s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 17:18:53 -04:00
parent ab6c3a1354
commit 85cea8d559
3 changed files with 22 additions and 9 deletions
@@ -101,18 +101,30 @@ class MinstrelForwardingPlayer(
override fun seekToNextMediaItem() { override fun seekToNextMediaItem() {
val active = holder.active.value val active = holder.active.value
super.seekToNextMediaItem() if (active == null) {
if (active != null) { super.seekToNextMediaItem()
scope.launch { syncCurrentItemToRemote(active) } 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() { override fun seekToPreviousMediaItem() {
val active = holder.active.value val active = holder.active.value
super.seekToPreviousMediaItem() if (active == null) {
if (active != null) { super.seekToPreviousMediaItem()
scope.launch { syncCurrentItemToRemote(active) } 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 = override fun getCurrentPosition(): Long =
@@ -135,8 +147,8 @@ class MinstrelForwardingPlayer(
private suspend fun syncCurrentItemToRemote(active: ActiveUpnp) { private suspend fun syncCurrentItemToRemote(active: ActiveUpnp) {
val mediaId = handlerEvaluate { delegate.currentMediaItem?.mediaId } ?: return val mediaId = handlerEvaluate { delegate.currentMediaItem?.mediaId } ?: return
val token = runCatching { tokens.mint(mediaId) }.getOrNull() ?: return
runCatching { runCatching {
val token = tokens.mint(mediaId)
active.avTransport.setAVTransportURIWithMetadata( active.avTransport.setAVTransportURIWithMetadata(
uri = token.url, uri = token.url,
mime = token.mime, mime = token.mime,
@@ -148,6 +160,7 @@ class MinstrelForwardingPlayer(
} }
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) { private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
pollJob?.cancel()
Timber.w(t, "UPnP transport call failed on %s", active.routeName) Timber.w(t, "UPnP transport call failed on %s", active.routeName)
remoteState.applyError(t) remoteState.applyError(t)
handler.post { onDrop(active.routeName) } handler.post { onDrop(active.routeName) }
@@ -22,7 +22,7 @@ class RemotePlayerState {
@Volatile var currentTrackUri: String = ""; private set @Volatile var currentTrackUri: String = ""; private set
@Volatile var lastError: Throwable? = null; 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) { fun applyPositionInfo(positionMs: Long, durationMs: Long, trackUri: String) {
this.positionMs = positionMs this.positionMs = positionMs
@@ -45,7 +45,7 @@ data class RouteSnapshot(
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in, * - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
* wired, Bluetooth) * wired, Bluetooth)
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via * - [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 * AVTransport.SetAVTransportURI + Play, pause local playback so
* audio yields to the network speaker * audio yields to the network speaker
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] — * - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —