Part A: split loadQueueOnSonos into an initial phase (tracks[0..currentIndex]
only, then SetAV+Seek+Play) plus a background extendQueueOnSonos coroutine
that appends the remainder after activation. Reduces the UPnP activation
block from ~17s (100 tracks serial) to ~200ms (1 track at currentIndex=0).
Background extension cancels cleanly when activeUpnpHolder.active changes.
Part B: add PlayerUiState.isUpnpLoading (target set, active null). Projected
inline in onEvents so it stays consistent with the rest of the snapshot, plus
a separate combine(target, active) collector that updates uiState between
player-event fires. NowPlayingScreen.TransportRow and MiniPlayer.MiniRow
replace the play/pause icon with a CircularProgressIndicator while loading
and disable the button tap to prevent premature commands to the Sonos queue.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Polling alone cannot distinguish Sonos auto-advancing via SetNextAVTransportURI
from URI changes we made ourselves via syncCurrentItemToRemote. This produced
two races: (1) activation race -- first poll returns stale URI from prior
session, second returns new URI, false-positive fires and double-advances the
cursor; (2) user-skip race -- skip's syncCurrentItemToRemote changes the URI,
next poll sees the change and fires again. Remove the detection block and
previousTrackUri capture from pollOnce entirely. pollLoop is now a pure
state-tracker (position + transport state) plus the one-shot initial pre-queue
gate. GENA event subscriptions to AVTransport LastChange are the correct fix;
deferred to its own slice (see parity-map).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add optional onRawResponse callback to SoapClient; loggingSoapClient
factory emits the first 6 GetPositionInfo/GetTransportInfo bodies
(3 poll cycles) at WARN so release logs capture them. Wire into
transportFor so every AVTransportClient for a new UPnP session logs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
- OutputPickerController.selectUpnp: pause ExoPlayer BEFORE setting
activeUpnpHolder so ForwardingPlayer.pause() routes to ExoPlayer,
not SOAP; remove now-redundant playerController.pause() from inside
runCatching; bump activation Timber.i -> Timber.w for release logcat
- MinstrelForwardingPlayer: remove Player.Listener onMediaItemTransition
that raced with seekToNext/Prev override's syncCurrentItemToRemote;
seekToNext/Prev now launch sync -> preQueueNext sequentially in one
coroutine; remove early preQueueNext from onActiveChanged (raced with
selectUpnp SOAP); move initial pre-queue to pollLoop, fires once
trackUri lands confirming Sonos accepted SetAV+Play
- Extract pollOnce from pollLoop to stay within detekt LongMethod=60;
natural-advance branch now calls preQueueNext explicitly (no listener)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OutputPickerController now owns selection state for the UPnP leg and
runs the disconnect flow when the user picks a system route while a
renderer is active.
- Inject OkHttpClient + RemotePlayerState so we can build a
RenderingControlClient at selection time and capture the last-known
remote position on disconnect.
- selectUpnp publishes ActiveUpnp(routeId, routeName, avTransport,
rendering) to ActiveUpnpHolder, marks the route id in
selectedUpnpRouteIdInternal, and honors Sonos topology by routing
through coordinatorRouteFor before SOAP.
- selectSystem now does the disconnect: AVTransport.Stop -> clear the
holder -> seek local ExoPlayer to the remembered position -> resume
if the remote was playing.
- routesState combines 4 sources (system, UPnP, Sonos topology,
upnp-selected id). Non-coordinator Sonos members are filtered out
of the visible list. current resolves from the merged list when a
UPnP route is selected; otherwise from the system snapshot.
- sortRoutes drops the current-first rule -- BuiltIn "Phone speaker"
pins to the top, everything else lowercase-alphabetical. Selection
state moves to the radio-button indicator in the picker row.
- RemotePlayerState gets @Singleton + @Inject constructor() so Hilt
can provide the shared instance to both the picker and the
forthcoming MinstrelForwardingPlayer.
Task 7 of UPnP transport-parity slice. Introduces the central
ForwardingPlayer that branches between local ExoPlayer and the active
UPnP renderer:
- MinstrelForwardingPlayer wraps the delegate Player; play/pause/seek
and the next/previous transport calls translate to AVTransport SOAP
when an ActiveUpnp is set, otherwise forward to super. Position +
isPlaying + duration + playbackState reads pull from
RemotePlayerState while remote.
- 1Hz poll loop drives GetPositionInfo + GetTransportInfo, feeding
RemotePlayerState; the rolling-3 failure heuristic fires onDrop on
the looper for the factory to surface as a snackbar.
- StreamTokenProvider extracts the CastApi.create() Retrofit wiring
into a Hilt singleton so the service-side player and the
controller-side picker share one CastApi instance.
- OutputPickerController constructor swaps Retrofit for
StreamTokenProvider + ActiveUpnpHolder (the holder is wired now for
Task 8). selectUpnp now mints via streamTokens.mint(trackId).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DIDL assertion now checks for XML-escaped form (<dc:title>) since
SoapClient.buildEnvelope escapes all arg values. Lifts xmlEscape to a
top-level internal fun in SoapClient.kt, removing the duplicate private
copy from AVTransportClient. Fixes @Suppress rationale (not Compose).
Renames seek test to reflect colon-separated format; adds unknown-state
getTransportInfo test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After DIDL fix, Sonos accepted SetAVTransportURI + Play, but
playerController.pause() threw IllegalStateException 'method is
called from a wrong thread' because selectUpnp runs the whole
UPnP-selection flow on Dispatchers.Default. UI tap handlers were
fine - they're already on Main - but the cross-thread background
call from OutputPickerController.selectUpnp hit the MediaController's
application-thread guard.
Same fix as the cold-boot resume one earlier today (commit e69a5204
wrapped setQueue): pause / play / seekTo / skipToNext / skipToPrevious
now route through runOnControllerThread, which is a no-op when
already on the application looper and Handler.post otherwise.
Logcat from on-device confirmed Sonos plays after this fix lands -
SetAVTransportURI -> 200, Play -> 200, then the IllegalStateException
was the last failure path.
After the X-Forwarded-Proto fix Sonos now gets a clean https:// URL
but returns vendor error 1023 - empty CurrentURIMetaData. Sonos
requires DIDL-Lite metadata with at minimum <res protocolInfo>
carrying the audio MIME type so it can validate the source before
playback. The original spec said 'Sonos accepts empty DIDL; recoverable
if a device rejects' - that was wrong for Sonos.
Server (cast_token.go):
- Look up the track and return mime (from tracks.file_format) +
title in the cast-token response. mimeForFormat covers the common
formats - mp3, flac, m4a/aac, ogg, opus, wav - falling through to
audio/mpeg for unknowns.
- Missing track returns 404 (apierror.NotFound) instead of letting the
caller mint a token for nothing.
Client (CastApi.kt, AVTransportClient.kt, OutputPickerController.kt):
- StreamTokenResponse gains mime + title (defaulted so old contracts
stay parseable).
- AVTransportClient.setAVTransportURIWithMetadata builds minimal Sonos-
acceptable DIDL-Lite around the URL + MIME + title. xml-escaped.
- selectUpnp calls the new overload; Timber.i now logs the MIME so the
next on-device test shows it.
Generic UPnP renderers tolerate the DIDL shape too - no downside to
sending it everywhere.
Debug builds got DebugTree; release builds had no tree planted at
all, so Timber.w / Timber.e calls were dropped silently in
production. That's how the UPnP select diagnostic-prints went
invisible during on-device testing - the released APK had no Timber
output reaching logcat.
Plant a release-only Tree that emits at WARN and above via
android.util.Log.println with the canonical 'Minstrel' tag (or the
caller-supplied tag when present). Keeps DEBUG / INFO traffic out of
production logcat (the chatty stuff is the part we don't want
flooding the buffer) while letting operator-driven adb logcat
sessions still see real failures.
Two silent early returns in selectUpnp were swallowing the most
likely failure modes:
- currentTrack null (nothing playing locally → can't cast a track)
- transportFor() returns null (route disappeared or id mismatch)
On-device verification reported 'tap collapses the sheet but no
audio routes', with logcat empty - one of these was firing without
any signal.
Each early-return now Timber.w's why; the runCatching block adds
Timber.i breadcrumbs at every step (mint token, SetAVTransportURI,
Play, done) so the next failure shows exactly how far we got.
Sonos uses the friendlyName format
'Room - Device Type - RINCON_<UDN>'
The picker was showing it verbatim, so the user saw rows like
'Living Room - Sonos Play:1 Media Renderer - RINCON_5CAAFD79...'
Now strips on the first ' - ' for Sonos manufacturer matches, so the
chip shows just 'Living Room' / 'Kitchen' / etc. Subtitle (manufacturer
+ model) still renders below per the existing sheet design, so the
device-type info isn't lost.
Generic UPnP devices that append a '(192.168.x.x)' IP suffix get that
stripped too via an end-of-string-anchored regex. Empty / blank
friendlyName still falls back to 'Network speaker'.
Two independent bugs surfaced together:
Bug 1: like button on tracks in playlist/album detail screens didn't
reflect actual liked state. LikesRepository.observeLikedTracks() does
a mapNotNull join against trackDao - a liked track whose row isn't in
the local cache yet (e.g. liked via web/notification, cache not
sync'd) gets DROPPED from the returned list. PlaylistDetailViewModel
+ AlbumDetailViewModel both used that as their like-set source, so
those rows showed as not-liked.
Adds LikesRepository.observeLikedTrackIds(): Flow<Set<String>> that
hits the DAO directly via observeLikedIdsOfType - no trackDao join,
no drops. The two ViewModels switch to it. LikedTab continues to use
observeLikedTracks because it needs the full TrackRef to render.
Bug 2: playlist cover art didn't render on the playlist detail
header. Server's derivePlaylistView returns CoverURL as the relative
path "/api/playlists/<id>/cover". PlaylistsRepository's two domain
mappers (CachedPlaylistEntity.toDomain + PlaylistDetailWire
.toPlaylistRef) stored it verbatim - Coil's AsyncImage can't fetch a
relative URL with no base, so the image silently failed.
Wraps the coverPath/coverUrl through resolveServerUrl so the
placeholder.invalid host triggers BaseUrlInterceptor's live-server
rewrite, same idiom every other cover surface (album / artist / track
/ playlist track rows) already uses.
System-playlist 24h refresh investigation pending - need to know how
you verified (server logs, DB state, or client-visible content)
before I can dig into the right layer.
Code-quality review flagged two non-blockers on commit 03cdff54:
1. selectUpnp's runCatching swallowed SOAP / token-mint failures
silently - OkHttp's logger doesn't see them since they happen in
our own deserialize / parse code. Adds Timber.w on the failure
path so operator's on-device Sonos verification can find the
cause in logcat instead of staring at "nothing happened".
2. UpnpDiscoveryController's fetchJob field was assigned but never
read or cancelled. appScope is process-lifetime so the launched
coroutine dies with the process - no explicit cancellation is
needed. Drop the field + the now-unused Job import.
UpnpDiscoveryController - Hilt singleton that owns the SSDP listener,
follows each discovered LOCATION URL to fetch + parse the device
description, projects MediaRenderers into a StateFlow<List<UpnpRoute>>.
OutputPickerController now combines system routes with the UPnP
Flow into a unified RouteSnapshot. select() branches by protocol:
SYSTEM goes through MediaRouter as before; UPNP requests a signed
stream token via POST /api/cast/stream-token then calls
AVTransport.SetAVTransportURI + Play against the discovered device.
Local playback pauses on UPnP selection.
OutputPickerSheet gains a MulticastHintRow shown when no UPnP
devices appear after a 3s grace period - the 'your router may be
blocking multicast' footer hint per the spec.
Closes the UPnP slice spec'd in
docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
On-device verification pending: pair a Sonos / UPnP speaker, confirm
discovery + selection + playback + the multicast-blocked hint.
SoapClient - minimal SOAP 1.1 envelope builder + POST via the shared
app OkHttpClient. Sets the SOAPACTION + Content-Type headers UPnP
expects, parses the action's Response element as a Map<String,
String>, raises SoapFaultException on a <s:Fault> response with the
UPnP errorCode + errorDescription extracted.
AVTransportClient - thin wrapper over SoapClient pinned to the
AVTransport:1 service. Three actions for v1: SetAVTransportURI /
Play / Stop. Pause + Seek deferred until we have hardware in the
loop to verify per-device quirks.
Three MockWebServer-driven unit tests cover the SOAPACTION header
shape, XML escaping of special chars in arg values, and the fault
response -> SoapFaultException path. kxml2 on the test classpath
(Task 4) makes XmlPullParserFactory resolve on the JVM.
Android's XmlPullParserFactory is a Stub-throwing class in android.jar
on the JVM unit-test classpath; the probe pattern from dc5b8252 was
silently skipping the test suite, which gives false test-coverage
confidence. kxml2 is the same parser implementation Android uses
internally - service-provider lookup picks it up automatically once
on the test classpath.
The probe + Assumptions.assumeTrue skip removed; tests now run
unconditionally.
testImplementation(libs.kxml2) - 2.3.0, MIT-licensed, ~80KB. No
production code change.
SsdpDiscovery - UDP multicast listener on 239.255.255.250:1900.
Passive NOTIFY listen always-on once start() is called; explicit
M-SEARCH M-SEARCH on requestActiveScan() (called when picker sheet
opens). WifiManager.MulticastLock held only while running. Emits
each discovered LOCATION URL on a SharedFlow for downstream
description-fetching.
DeviceDescription - pull-parse the <device> XML returned from a
LOCATION URL, extracting friendlyName / manufacturer / modelName +
AVTransport + RenderingControl service control URLs. Filters out
devices without AVTransport (we can't control them).
Three unit tests cover a Sonos-shaped description, a non-renderer
device that should be dropped, and a minimal description with
missing optional fields.