Player: unify local+UPnP behind one authoritative cursor (#171) + queue heart button (#1596) #110

Merged
bvandeusen merged 4 commits from dev into main 2026-07-15 16:30:52 -04:00
Owner

Two features on dev, both CI-green (android.yml run 2380, test-web run 2371).

#171 — UPnP/Sonos: one authoritative playback cursor

The local ExoPlayer cursor and the Sonos renderer were two competing sources of truth during a cast. The local cursor lagged (forward-only, index-based sync, skipped during load/re-cast) and two writers of the current index fought — so the in-app player flickered to the pre-cast track and the notification metadata went stale.

  • MinstrelForwardingPlayer now aligns the paused local cursor to the track the renderer is actually playing, matched by track-id parsed from the Sonos URI vs delegate MediaItem.mediaId — both directions, survives queue-reload index wobble.
  • PlayerController: the position tick patches only position/duration/play-pause/buffer; onEvents is the sole writer of the index/track. One source, no stomp.
  • Falls out for free: cast→local handoff resumes on the correct track+position (was the stale track); the notification now shows the real track.
  • Resolves the index-drift half of #1211; builds on the June UPnP work (#606/#608/#612).

#1596 — heart/like button in the full-screen player queue

The queue ("up next") rows in the full-screen player were the one track-list surface without a like heart, on both clients. Added the shared LikeButton to the web QueueTrackRow and the Android QueueScreen row (set-based liked-ids, same idiom as the detail screens). Liked state stays sourced from the shared store by track id — no data-model change.

Verification

  • CI: green on both lanes on dev head.
  • UPnP needs a device cast session (CI can't exercise Sonos) — see the release notes for the checklist. This release is cut specifically so that on-device verification can happen against the signed APK.

🤖 Generated with Claude Code

Two features on `dev`, both CI-green (android.yml run 2380, test-web run 2371). ## #171 — UPnP/Sonos: one authoritative playback cursor The local ExoPlayer cursor and the Sonos renderer were two competing sources of truth during a cast. The local cursor lagged (forward-only, index-based sync, skipped during load/re-cast) and two writers of the current index fought — so the in-app player flickered to the pre-cast track and the notification metadata went stale. - **MinstrelForwardingPlayer** now aligns the paused local cursor to the track the renderer is *actually* playing, matched by track-id parsed from the Sonos URI vs delegate `MediaItem.mediaId` — both directions, survives queue-reload index wobble. - **PlayerController**: the position tick patches only position/duration/play-pause/buffer; `onEvents` is the sole writer of the index/track. One source, no stomp. - Falls out for free: cast→local handoff resumes on the correct track+position (was the stale track); the notification now shows the real track. - Resolves the index-drift half of #1211; builds on the June UPnP work (#606/#608/#612). ## #1596 — heart/like button in the full-screen player queue The queue ("up next") rows in the full-screen player were the one track-list surface without a like heart, on both clients. Added the shared LikeButton to the web `QueueTrackRow` and the Android `QueueScreen` row (set-based liked-ids, same idiom as the detail screens). Liked state stays sourced from the shared store by track id — no data-model change. ## Verification - CI: green on both lanes on `dev` head. - **UPnP needs a device cast session** (CI can't exercise Sonos) — see the release notes for the checklist. This release is cut specifically so that on-device verification can happen against the signed APK. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bvandeusen added 4 commits 2026-07-15 16:30:45 -04:00
feat(player): heart/like button in queue view (web + android) — #1596
test-web / test (push) Failing after 33s
android / Build + lint + test (push) Successful in 4m25s
235839b696
The full-screen player's queue ("up next") track rows were the one
track-list surface missing the like heart that TrackRow/PlaylistTrackRow
(web) and playlist/album/artist detail (Android) already carried.

Web: render the shared <LikeButton> in QueueTrackRow between the row body
and the remove button (serves both the /now-playing aside and the mobile
QueueDrawer, same component). LikeButton already stops click propagation
so it won't trigger play-on-click.

Android: PlayerViewModel now exposes likedTrackIds (set-based, the same
idiom as the detail VMs) + toggleLikeTrack; QueueScreen threads
liked/onToggleLike through QueueList → QueueRow, which renders the shared
LikeButton after the duration. Liked state stays sourced from
LikesRepository by track.id — no TrackRef data-model change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QueueTrackRow now renders a LikeButton, which reads createLikedIdsQuery
and needs a QueryClient in Svelte context. The QueueTrackRow / QueueDrawer
unit tests render the rows without one, so they failed with "No
QueryClient was found in Svelte context". Mock $lib/api/likes with the
shared emptyLikesMock() helper — the same pattern PlayerBar/TrackMenu and
17 other component tests already use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prior test fix registered the emptyLikesMock stub but imported it
(and the component under test) in the wrong order: importing QueueTrackRow
/ QueueDrawer transitively loads LikeButton → the mocked $lib/api/likes,
whose hoisted factory runs before the emptyLikesMock import initialized —
"Cannot access '__vi_import_N__' before initialization".

Move the emptyLikesMock import above, and the component import below, the
vi.mock call — matching the ArtistMenu/PlayerBar test layout so the
factory's binding is ready when the component graph loads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(player): identity-locked UPnP cursor + single index writer — #171
android / Build + lint + test (push) Successful in 4m23s
41fe76b90c
The local ExoPlayer cursor and the Sonos renderer were two competing
sources of truth for "what's playing" during a cast. The delegate cursor
lagged (forward-only, index-based, size-capped sync, skipped during every
load/re-cast window), and TWO writers of PlayerUiState.queueIndex fought:
PlayerController.onEvents (reading the lagging cursor) stomped the
Sonos-derived index the position tick published, so the in-app player
flickered to the pre-cast track and the notification metadata went stale.

Step 1 — MinstrelForwardingPlayer.syncLocalCursorToRemote (replaces
maybeSyncLocalCursor): align the paused delegate cursor to the track the
renderer is actually playing, matched by track-id parsed from the Sonos
TrackURI (/api/tracks/{id}/stream) against delegate MediaItem.mediaId
(== TrackRef.id). Both directions; survives queue-reload index wobble;
nearest-occurrence tiebreak for duplicate tracks; falls back to the Sonos
Track index; suppressed during load and while a user transport is pending
Sonos's ack. The cursor is now the single authoritative "current track"
that both the in-app UI (onEvents) and the notification (getCurrentMediaItem)
read.

Step 2 — PlayerController: the position tick now patches only
position/duration/play-pause/buffer; onEvents is the sole writer of
queueIndex/currentTrack. Removed desiredQueueIndex, the forward-only
trackChanged path, and publishTickIfChanged. One writer, no stomp.

Part of milestone #171 (unify local + UPnP behind one cursor). Fixes the
flicker + stale-notification symptoms; supersedes #1211/#608/#612.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bvandeusen merged commit 2ecdd46a2b into main 2026-07-15 16:30:52 -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#110