dev → main: Android UPnP/Sonos transport parity + server stream URL extension #79

Merged
bvandeusen merged 69 commits from dev into main 2026-06-04 08:15:15 -04:00
2 changed files with 22 additions and 6 deletions
Showing only changes of commit 673f98487f - Show all commits
@@ -206,6 +206,13 @@ class MinstrelForwardingPlayer(
pollJob?.cancel()
if (active != null) {
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
// Pause the wrapped ExoPlayer so we are not playing local audio
// simultaneously with the remote renderer. handler.post targets the
// application looper, so this runs on the same thread that processes
// our override calls -- no race with the pause() override branching
// to SOAP (holder.active is already non-null by the time this post
// fires, but delegate.pause() bypasses the override entirely).
handler.post { delegate.pause() }
pollJob = scope.launch { pollLoop(active) }
// Do NOT pre-queue here: selectUpnp's SetAV+Play SOAP may not have
// landed yet. Initial pre-queue fires from pollLoop once the first
@@ -225,6 +232,14 @@ class MinstrelForwardingPlayer(
// SetAV+Play from selectUpnp (trackUri lands in the first
// successful poll). Firing from onActiveChanged would race
// with selectUpnp's SOAP calls.
if (!initialPreQueueDone) {
Timber.w(
"UPnP poll OK: route=%s trackUri=%s posMs=%d",
active.routeName,
remoteState.currentTrackUri,
remoteState.positionMs,
)
}
if (!initialPreQueueDone && remoteState.currentTrackUri.isNotBlank()) {
Timber.w("UPnP initial pre-queue for %s", active.routeName)
preQueueNext(active)
@@ -258,12 +258,13 @@ class OutputPickerController @Inject constructor(
return@withLock
}
val rendering = renderingClientFor(effectiveRoute.id)
// Pause local FIRST while holder.active is still null so the pause()
// call routes to ExoPlayer, not SOAP. After holder.set() below,
// ForwardingPlayer.pause() would translate this into AVTransport.Pause()
// -- the opposite of what we want, since we are about to tell Sonos
// to start playing.
playerController.pause()
// Local pause is handled by MinstrelForwardingPlayer.onActiveChanged
// via handler.post { delegate.pause() } -- called immediately after
// holder.set() fires. That path bypasses the SOAP-routing override, so
// there is no race between pausing ExoPlayer and starting the remote
// renderer. Do NOT call playerController.pause() here: it dispatches
// through Handler.post on the application looper, meaning it runs after
// holder.active is non-null and the override would route it to SOAP.
selectedUpnpRouteIdInternal.value = effectiveRoute.id
activeUpnpHolder.set(
ActiveUpnp(