fix(android): UPnP local pause via delegate -- no SOAP race
android / Build + lint + test (push) Failing after 1m30s

Move local ExoPlayer pause from OutputPickerController.selectUpnp
into MinstrelForwardingPlayer.onActiveChanged (handler.post { delegate.pause() }).
This guarantees the pause hits ExoPlayer before the holder is live, eliminating
the async race that caused SOAP fault 701 on Sonos when pause() was dispatched
via playerController after holder.active was already set.
Also adds per-poll Timber.w before initialPreQueueDone for diagnostics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 19:53:41 -04:00
parent c556388a6b
commit 673f98487f
2 changed files with 22 additions and 6 deletions
@@ -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(