Sonos queue resync + cold-start prefetcher gate #83

Merged
bvandeusen merged 3 commits from dev into main 2026-06-04 17:36:31 -04:00
Owner

Three commits on dev since the v2026.06.04 hotfix re-cut. All caught on-device.

80179343 — Sonos queue resync on playlist switch (full reload)

Tapping a different playlist while Sonos was the active route updated the player view but Sonos kept playing the OLD queue. PlayerController.setQueue replaced the local ExoPlayer queue and called play(), which forwarded SOAP Play to Sonos — but Sonos's native queue (loaded once at route selection via removeAllTracks + AddURIToQueue + SetAVTransportURI) was never refreshed.

  • MinstrelForwardingPlayer.setMediaItems (all 3 overloads) clears holder.active + sets target synchronously so the immediately-following play() drops via isLoadingUpnp().
  • OutputPickerController observes uiState.queue identity changes and runs a loadQueueOnSonos reload when the joined-ids key shifts.

e7d7cb24 — Incremental Sonos diff for playNext + radio-append

The first commit re-loaded Sonos's full queue on every uiState.queue identity change. Correct for playlist-switch but disruptive for in-queue mutations — playNext and radio-append would restart the currently-playing track on Sonos because removeAllTracks + AddURIToQueue x N + SetAVTransportURI re-anchors the transport.

Now the resync runs a longest-common-prefix / suffix diff first. When the current Sonos track lies in the preserved prefix, applies the minimum-incremental SOAP operations: RemoveTrackRangeFromQueue on the removed middle + AddURIToQueue at the same insertion point. Sonos keeps playing the current track and the new entries land in place. Falls back to the full reload only when the diff would orphan playback (the playlist-switch case from the prior commit).

Adds AVTransportClient.removeTrackRangeFromQueue (Sonos-specific, UpdateID=0 skips the queue-version check).

Cases covered:

  • Playlist switch → full reload (current track replaced, prefix=0)
  • playNext insert → 1 AddURIToQueue at slot currentIdx+2
  • Radio-append → RemoveTrackRangeFromQueue for old tail + N AddURIToQueue for new tracks

9a31955f — Gate AudioPrefetcher on isPlaying

On-device logcat showed cold-start playback taking ~25s on fresh install. AudioPrefetcher was kicking off N concurrent CacheWriter jobs the instant setQueue updated uiState — each a full upcoming-track download over the same OkHttp client as the playback DataSource. Five-way bandwidth split plus parallel Coil cover fetches starved the current track until its full body had streamed (~12 MB at ~1 MB/s under contention).

Now reconcile() observes uiState.isPlaying and starts upcoming-track prefetches only when the current track is actually playing. Cancellation of out-of-window jobs still always runs so a queue switch or skip frees the pipe immediately, even while paused.

Expected cold-start time-to-play: ~25s → ~5-7s on the user's network (single-stream throughput plus one-time TLS/DNS tax).

Test plan

  • Sonos: tap a different playlist while Sonos is playing → audio switches to the new queue immediately, no audio drop while reload runs.
  • Sonos: tap "Play next" on a track while another plays → inserted track plays AFTER current, current doesn't restart.
  • Sonos: start radio from a track → trailing queue replaced with radio picks, current track keeps playing.
  • Fresh install + cold play → first audio within ~5-7s, not ~25s. No multi-tap impatience.
  • Pause + resume → no behavior regression. Skips still cancel prefetches as before.
Three commits on dev since the v2026.06.04 hotfix re-cut. All caught on-device. ## 80179343 — Sonos queue resync on playlist switch (full reload) Tapping a different playlist while Sonos was the active route updated the player view but Sonos kept playing the OLD queue. `PlayerController.setQueue` replaced the local ExoPlayer queue and called `play()`, which forwarded SOAP Play to Sonos — but Sonos's native queue (loaded once at route selection via `removeAllTracks + AddURIToQueue + SetAVTransportURI`) was never refreshed. - `MinstrelForwardingPlayer.setMediaItems` (all 3 overloads) clears `holder.active` + sets target synchronously so the immediately-following `play()` drops via `isLoadingUpnp()`. - `OutputPickerController` observes `uiState.queue` identity changes and runs a `loadQueueOnSonos` reload when the joined-ids key shifts. ## e7d7cb24 — Incremental Sonos diff for playNext + radio-append The first commit re-loaded Sonos's full queue on every uiState.queue identity change. Correct for playlist-switch but disruptive for in-queue mutations — `playNext` and radio-append would restart the currently-playing track on Sonos because `removeAllTracks + AddURIToQueue x N + SetAVTransportURI` re-anchors the transport. Now the resync runs a longest-common-prefix / suffix diff first. When the current Sonos track lies in the preserved prefix, applies the minimum-incremental SOAP operations: `RemoveTrackRangeFromQueue` on the removed middle + `AddURIToQueue` at the same insertion point. Sonos keeps playing the current track and the new entries land in place. Falls back to the full reload only when the diff would orphan playback (the playlist-switch case from the prior commit). Adds `AVTransportClient.removeTrackRangeFromQueue` (Sonos-specific, `UpdateID=0` skips the queue-version check). Cases covered: - Playlist switch → full reload (current track replaced, prefix=0) - `playNext` insert → 1 `AddURIToQueue` at slot `currentIdx+2` - Radio-append → `RemoveTrackRangeFromQueue` for old tail + N `AddURIToQueue` for new tracks ## 9a31955f — Gate AudioPrefetcher on isPlaying On-device logcat showed cold-start playback taking ~25s on fresh install. `AudioPrefetcher` was kicking off N concurrent `CacheWriter` jobs the instant `setQueue` updated `uiState` — each a full upcoming-track download over the same OkHttp client as the playback `DataSource`. Five-way bandwidth split plus parallel Coil cover fetches starved the current track until its full body had streamed (~12 MB at ~1 MB/s under contention). Now `reconcile()` observes `uiState.isPlaying` and starts upcoming-track prefetches only when the current track is actually playing. Cancellation of out-of-window jobs still always runs so a queue switch or skip frees the pipe immediately, even while paused. Expected cold-start time-to-play: ~25s → ~5-7s on the user's network (single-stream throughput plus one-time TLS/DNS tax). ## Test plan - [ ] Sonos: tap a different playlist while Sonos is playing → audio switches to the new queue immediately, no audio drop while reload runs. - [ ] Sonos: tap "Play next" on a track while another plays → inserted track plays AFTER current, current doesn't restart. - [ ] Sonos: start radio from a track → trailing queue replaced with radio picks, current track keeps playing. - [ ] Fresh install + cold play → first audio within ~5-7s, not ~25s. No multi-tap impatience. - [ ] Pause + resume → no behavior regression. Skips still cancel prefetches as before.
bvandeusen added 3 commits 2026-06-04 17:36:26 -04:00
fix(android): re-prime Sonos queue when user plays a different playlist
android / Build + lint + test (push) Successful in 3m56s
8017934334
Before: tapping a different playlist while Sonos was the active route
updated the player view but Sonos kept the old queue and played those
tracks (or whatever was last there). PlayerController.setQueue replaced
the local ExoPlayer queue and called play(), which forwarded SOAP Play
to Sonos -- but Sonos's native queue (loaded once at route selection
via removeAllTracks + AddURIToQueue + SetAVTransportURI) was never
touched on subsequent setQueue calls.

Now: MinstrelForwardingPlayer.setMediaItems (all 3 overloads) clears
holder.active + sets target synchronously so the immediately-following
play() drops via isLoadingUpnp(). OutputPickerController observes
uiState.queue identity changes; when target or active is non-null and
the queue key shifted, it re-runs loadQueueOnSonos under the existing
selectUpnpMutex and restores active when done. Sonos resync failures
drop cleanly to local (selectedUpnpRouteIdInternal nulled).

Doesn't touch addMediaItem / radio-append paths -- those leave Sonos's
queue stale and need a separate AddURIToQueue extension hook; out of
scope for this fix.
fix(android): incremental Sonos queue sync for playNext + radio-append
android / Build + lint + test (push) Successful in 3m55s
e7d7cb2471
The previous fix re-loaded Sonos's full queue on every uiState.queue
identity change -- correct for playlist-switch (full replacement) but
disruptive for in-queue mutations: playNext and radio-append would
restart the currently-playing track on Sonos because removeAllTracks
+ AddURIToQueue x N + SetAVTransportURI re-anchors the transport.

Now the resync runs a longest-common-prefix / common-suffix diff first.
When the current Sonos track lies in the preserved prefix, applies the
minimum-incremental SOAP operations -- RemoveTrackRangeFromQueue on the
removed middle, AddURIToQueue at the same insertion point -- so Sonos
keeps playing the current track and the new entries land in place
without interrupting playback. Falls back to the full removeAllTracks
reload when the current track is in the removed slice (playlist
switch).

Adds AVTransportClient.removeTrackRangeFromQueue (Sonos-specific,
UpdateID=0 skips the queue-version check).

Cases now covered:
  - Playlist switch -> full reload (current track replaced, prefix=0)
  - playNext insert  -> 1 AddURIToQueue at the right slot
  - Radio-append     -> RemoveTrackRangeFromQueue for old tail + N
                        AddURIToQueue for new tracks at the end
perf(android): gate audio prefetch on isPlaying
android / Build + lint + test (push) Successful in 3m41s
9a31955fa4
Cold-start playback on a fresh install was taking ~25 s before any
audio played. Logcat showed AudioPrefetcher was kicking off N
concurrent CacheWriter jobs the instant setQueue updated uiState --
each prefetch a full upcoming-track download over the same OkHttp
client as the current-track DataSource. Five-way bandwidth split
plus parallel Coil cover fetches starved the current track until
its full file body had streamed through (~12 MB at ~1 MB/s under
contention).

Now reconcile() observes uiState.isPlaying and starts upcoming-track
prefetches only when the current track is actually playing.
Cancellation of out-of-window jobs always runs so a queue switch or
skip still frees the pipe immediately, even while paused. Cold start
should drop from ~25 s -> 5-7 s on the user's network: just the
single-stream throughput plus the one-time TLS/DNS tax.

Refactored the inline reconcile body into computeTargets /
cancelOutOfWindowLocked / startInWindowLocked helpers to keep
ReturnCount under the detekt cap.
bvandeusen merged commit d6e6caa223 into main 2026-06-04 17:36:31 -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#83