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
Owner

Merges accumulated dev work to main. 1242 commits since last merge.

Headline changes

Android UPnP/Sonos transport parity (M8)

A full sweep across MinstrelForwardingPlayer, PlayerController, OutputPickerController, RemotePlayerState, and the polling/state machinery. Major fixes in this batch:

  • Lazy queue activationqueue[0..currentIndex] first, background-append rest. Cuts startup ~17s → 2-5s.
  • Loading spinner during the UPnP load window.
  • Service stays alive when UPnP playing (override getPlayWhenReady, fix onTaskRemoved).
  • Position interpolation between SOAP polls with 5s drift cap.
  • Force-poll on ON_RESUME so refocus catches up within RTT, not the full poll cycle.
  • Event-driven pending-transport model — Prev/Next press waits for Sonos's actual ack via remoteState.trackNumber match instead of a static timer; 5s safety timeout.
  • Forward-only track-change detection in the polling tick so a user press is never undone by stale poll data.
  • Polling tick stopped force-syncing the wrapped player via IPC — that re-invoked seekToTrack(idx+1) on Sonos and restarted whatever was playing on app resume.
  • effectiveDuration uses desiredIdx so duration tracks the displayed title during track changes.
  • DIDL-Lite refinements: restricted="true", id="-1", Rincon namespace, <desc cdudn> (some firmwares still ignore content for external HTTPS URIs).
  • Drop-threshold bumped 3→30 so screen-off WiFi sleep doesn't kill the route.
  • Track-change flicker debounce suppresses the brief PAUSED dip Sonos emits at boundaries.

Server: stream URL gets file extension

/api/tracks/{id}/stream.{ext} (Option A from task #610). Without the extension Sonos's URL probe couldn't identify the format, defaulted TrackDuration=0, and any seek triggered an immediate auto-advance. Backward-compatible: old /stream route still works.

Shuffle on system playlist tile play (Home + Playlists list)

Tile play button shuffles client-side after systemShuffle server call so rotation still advances. Snackbar + offline gating on Playlists list parity.

Cross-platform drift audit batches (1-7)

Closes a long list of small-to-medium drift issues across server, web, Android, Flutter. Notable: /api/me profile shape, GC sweep for 5 lifecycle gaps, scrobble queue retention, library SSR redirect, Android cross-device unlike, Android user-switch hygiene, cold-boot resume autoplay opt-out + awaitReady race, radio exclude cap.

Risk

Largest area is the Android UPnP rework — operator has been verifying on-device throughout. Server change is route-additive (no removed paths) with updated tests.

Test plan

  • On-device: Sonos TrackDuration is now a real value (was always 0:00:00)
  • On-device: seeking mid-track doesn't auto-advance to next song
  • On-device: Prev/Next no longer undone by stale poll
  • On-device: natural track advance updates UI within ~1s
  • On-device: app resume doesn't restart current song
  • CI green on dev (all checks passing at 8cd2383a)

🤖 Generated with Claude Code

Merges accumulated `dev` work to `main`. 1242 commits since last merge. ## Headline changes ### Android UPnP/Sonos transport parity (M8) A full sweep across `MinstrelForwardingPlayer`, `PlayerController`, `OutputPickerController`, `RemotePlayerState`, and the polling/state machinery. Major fixes in this batch: - **Lazy queue activation** — `queue[0..currentIndex]` first, background-append rest. Cuts startup ~17s → 2-5s. - **Loading spinner** during the UPnP load window. - **Service stays alive** when UPnP playing (override `getPlayWhenReady`, fix `onTaskRemoved`). - **Position interpolation** between SOAP polls with 5s drift cap. - **Force-poll on `ON_RESUME`** so refocus catches up within RTT, not the full poll cycle. - **Event-driven pending-transport** model — Prev/Next press waits for Sonos's actual ack via `remoteState.trackNumber` match instead of a static timer; 5s safety timeout. - **Forward-only track-change detection** in the polling tick so a user press is never undone by stale poll data. - **Polling tick stopped force-syncing the wrapped player via IPC** — that re-invoked `seekToTrack(idx+1)` on Sonos and restarted whatever was playing on app resume. - **`effectiveDuration` uses `desiredIdx`** so duration tracks the displayed title during track changes. - **DIDL-Lite refinements**: `restricted="true"`, `id="-1"`, Rincon namespace, `<desc cdudn>` (some firmwares still ignore content for external HTTPS URIs). - **Drop-threshold bumped** 3→30 so screen-off WiFi sleep doesn't kill the route. - **Track-change flicker debounce** suppresses the brief PAUSED dip Sonos emits at boundaries. ### Server: stream URL gets file extension `/api/tracks/{id}/stream.{ext}` (Option A from task #610). Without the extension Sonos's URL probe couldn't identify the format, defaulted `TrackDuration=0`, and any seek triggered an immediate auto-advance. Backward-compatible: old `/stream` route still works. ### Shuffle on system playlist tile play (Home + Playlists list) Tile play button shuffles client-side after `systemShuffle` server call so rotation still advances. Snackbar + offline gating on Playlists list parity. ### Cross-platform drift audit batches (1-7) Closes a long list of small-to-medium drift issues across server, web, Android, Flutter. Notable: `/api/me` profile shape, GC sweep for 5 lifecycle gaps, scrobble queue retention, library SSR redirect, Android cross-device unlike, Android user-switch hygiene, cold-boot resume autoplay opt-out + awaitReady race, radio exclude cap. ## Risk Largest area is the Android UPnP rework — operator has been verifying on-device throughout. Server change is route-additive (no removed paths) with updated tests. ## Test plan - [ ] On-device: Sonos `TrackDuration` is now a real value (was always `0:00:00`) - [ ] On-device: seeking mid-track doesn't auto-advance to next song - [ ] On-device: Prev/Next no longer undone by stale poll - [ ] On-device: natural track advance updates UI within ~1s - [ ] On-device: app resume doesn't restart current song - [ ] CI green on `dev` (all checks passing at `8cd2383a`) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bvandeusen added 69 commits 2026-06-04 08:15:08 -04:00
fix(server+android): DIDL-Lite metadata for Sonos UPnP (error 1023)
test-go / test (push) Successful in 29s
android / Build + lint + test (push) Successful in 4m14s
test-go / integration (push) Failing after 9m12s
24b7c92abd
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.
fix(android): PlayerController transport methods dispatch to controller thread
android / Build + lint + test (push) Successful in 4m17s
574bf29a7e
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.
feat(android): AVTransport pause/seek/nextURI/positionInfo/transportInfo
android / Build + lint + test (push) Failing after 1m35s
d62a3b8134
- Add pause(), seek(positionMs), setNextAVTransportURI(uri, mime, title)
- Add getPositionInfo() -> PositionInfo, getTransportInfo() -> TransportInfo
- Extract buildDidlLite() helper; add formatHhMmSs / parseHhMmSs helpers
- Add PositionInfo, TransportState, TransportInfo top-level types
- Add AVTransportClientTest covering all four new call shapes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(android): AVTransport test assertions escape DIDL; consolidate xmlEscape
android / Build + lint + test (push) Failing after 1m18s
3cdb416f94
DIDL assertion now checks for XML-escaped form (&lt;dc:title&gt;) 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>
feat(android): RenderingControl get/set volume
android / Build + lint + test (push) Failing after 1m33s
8c0c4c8600
test(android): RenderingControl lower clamp + GetVolume request body checks
android / Build + lint + test (push) Failing after 1m17s
799d50024c
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(android): Sonos ZoneGroupTopology client + parser
android / Build + lint + test (push) Failing after 1m27s
bfcb9c42a0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(android): UPnP in-place updates + Sonos topology aggregation
android / Build + lint + test (push) Failing after 1m14s
a5e4570f01
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(android): ActiveUpnpHolder singleton for picker -> player handoff
android / Build + lint + test (push) Failing after 1m24s
9a7d3b2d30
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(android): RemotePlayerState container for UPnP-synthesized player state
android / Build + lint + test (push) Failing after 1m28s
3aee2276bc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(android): MinstrelForwardingPlayer wraps ExoPlayer with UPnP branching
android / Build + lint + test (push) Failing after 1m25s
ab6c3a1354
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>
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.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ui(android): LazyColumn stable keys for in-place output picker row continuity
android / Build + lint + test (push) Failing after 1m21s
b2bfe96559
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(android): PlayerFactory wraps ExoPlayer in ForwardingPlayer + drop events
android / Build + lint + test (push) Failing after 1m35s
2f4d67d3c8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ui(android): drop snackbar + hardware volume keys when UPnP route active
android / Build + lint + test (push) Failing after 1m24s
29309d9bfb
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(android): UPnP volume cache keyed to active route id
android / Build + lint + test (push) Has been cancelled
81794e2475
feat(android): SetNextAVTransportURI pre-queue for gap-free UPnP advance
android / Build + lint + test (push) Failing after 1m35s
1ab21d81ca
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(android): UPnP drop falls back to local; pollLoop detects natural advance
android / Build + lint + test (push) Failing after 1m10s
edffdec2b2
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>
fix(android): UPnP local pause via delegate -- no SOAP race
android / Build + lint + test (push) Failing after 1m30s
673f98487f
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>
diag(android): log raw SOAP response body for first 3 UPnP polls
android / Build + lint + test (push) Failing after 1m22s
75132a2afe
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>
fix(android): Sonos x-rincon-mp3radio URI transform for SetAVTransportURI
android / Build + lint + test (push) Failing after 1m20s
8652b86f40
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(android): correct SOAPACTION assertions in next/previous tests
android / Build + lint + test (push) Successful in 3m58s
e20d7b1438
fix(android): skip zero-duration auto-advance during UPnP playback
android / Build + lint + test (push) Failing after 1m26s
e2866795ef
fix(android): handleZeroDurationIfNeeded ReturnCount within cap
android / Build + lint + test (push) Successful in 3m45s
e6c3c959fa
diag(android): log ForwardingPlayer transport overrides on entry
android / Build + lint + test (push) Successful in 3m50s
6129536153
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(android): lazy queue activation + loading spinner during UPnP load
android / Build + lint + test (push) Successful in 3m55s
87ad7f4dc2
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>
fix(android): refresh uiState.isPlaying from remoteState during UPnP
android / Build + lint + test (push) Successful in 4m1s
33285b53c6
fix(android): UPnP survives screen-off + cursor catches up to Sonos
android / Build + lint + test (push) Has been cancelled
8f89279fa4
fix(android): split playPlaylistShuffled to satisfy detekt ReturnCount
android / Build + lint + test (push) Failing after 3m42s
3576e241c0
fix(android): correct ErrorCopy import path in PlaylistPlayback
android / Build + lint + test (push) Failing after 6m2s
eae5dcad23
fix(android): onEvents reads UPnP state so resume keeps duration
android / Build + lint + test (push) Has been cancelled
e62fac3a0e
test(android): update RemotePlayerState threshold expectation to 30
android / Build + lint + test (push) Has been cancelled
47b0894ad6
fix(android): force poll on ON_RESUME + cap interpolation drift at 5s
android / Build + lint + test (push) Has been cancelled
389c896d65
fix(android): UPnP extend captures SOAP fault, aborts after 3 failures
android / Build + lint + test (push) Has been cancelled
c5b326c620
fix(android): DIDL-Lite includes Rincon namespace + cdudn desc for Sonos
android / Build + lint + test (push) Successful in 3m39s
5db90844cb
feat(server): stream URL gets file extension so Sonos can probe duration
test-go / test (push) Successful in 37s
test-go / integration (push) Failing after 10m34s
27bd38e005
test(server): seed track for cast-token tests + assert file-ext in URL
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 9m34s
8cd2383a42
bvandeusen merged commit d75c1ae37f into main 2026-06-04 08:15:15 -04:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/minstrel#79