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 543e58a4..3b35f103 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 @@ -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)