feat(android): SetNextAVTransportURI pre-queue for gap-free UPnP advance
android / Build + lint + test (push) Failing after 1m35s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 17:53:10 -04:00
parent 81794e2475
commit 1ab21d81ca
@@ -52,6 +52,15 @@ class MinstrelForwardingPlayer(
scope.launch {
holder.active.collect { active -> onActiveChanged(active) }
}
delegate.addListener(object : Player.Listener {
override fun onMediaItemTransition(
mediaItem: androidx.media3.common.MediaItem?,
reason: Int,
) {
val active = holder.active.value ?: return
scope.launch { preQueueNext(active) }
}
})
}
private fun isRemote(): Boolean = holder.active.value != null
@@ -159,6 +168,34 @@ class MinstrelForwardingPlayer(
}.onFailure { handleSoapFailure(active, it) }
}
/**
* Speculative pre-queue of the next track so Sonos can advance gap-free.
* Failures (token mint OR SetNextAVTransportURI rejection) silently fall
* back to the poll-driven advance path -- the pollLoop detects STOPPED +
* 0:00 RelTime and calls syncCurrentItemToRemote on whatever the new
* currentMediaItem is. The only consequence of failure here is a sub-second
* audible gap at track boundary, not a broken route.
*/
private suspend fun preQueueNext(active: ActiveUpnp) {
val nextMediaId = handlerEvaluate { nextItemMediaId() } ?: return
val token = runCatching { tokens.mint(nextMediaId) }.getOrNull() ?: return
runCatching {
active.avTransport.setNextAVTransportURI(
uri = token.url,
mime = token.mime,
title = token.title,
)
}.onFailure {
Timber.w(it, "SetNextAVTransportURI failed (poll-driven advance will fall back)")
}
}
private fun nextItemMediaId(): String? {
val nextIdx = delegate.nextMediaItemIndex
if (nextIdx == androidx.media3.common.C.INDEX_UNSET) return null
return delegate.getMediaItemAt(nextIdx).mediaId
}
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
pollJob?.cancel()
Timber.w(t, "UPnP transport call failed on %s", active.routeName)