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.
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)
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>
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>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.
MediaItem.mediaId— both directions, survives queue-reload index wobble.onEventsis the sole writer of the index/track. One source, no stomp.#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
QueueTrackRowand the AndroidQueueScreenrow (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
devhead.🤖 Generated with Claude Code
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>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>