feat(web): #415 stage 3 (web) — rotation-aware system playlist play

Web half of Stage 3. System-playlist tile play now:
- calls the new GET /api/playlists/system/{variant}/shuffle endpoint
  (rotation-aware order from the server) instead of getPlaylist;
  plays the returned order AS-IS — no client Fisher-Yates, since
  the server already ordered it. Supersedes #413's client shuffle
  for system playlists specifically; user playlists keep getPlaylist
  + stored order.
- tags the queue with the system variant. The player store carries
  _queueSource; the events dispatcher includes `source` on
  play_started so the server advances that playlist's rotation.

User playlists are unchanged (getPlaylist, plain playQueue, no
source). Tests updated: For-You play hits systemShuffle (not
getPlaylist/refresh) and passes source:for_you; user play uses
getPlaylist + plain playQueue with no source.

Flutter half is blocked — the Flutter client has no play-event
reporting at all (no /api/events POST, no scrobble), so there's no
play_started to attach `source` to. Surfacing that as a separate
decision rather than silently scope-exploding #415.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 07:57:00 -04:00
co-authored by Claude Opus 4.7
parent e43281d1d0
commit 179519689b
5 changed files with 91 additions and 23 deletions
+5 -1
View File
@@ -92,7 +92,11 @@ export function useEventsDispatcher(): void {
const res = await api.post<PlayStartedResponse>('/api/events', {
type: 'play_started',
track_id: trackId,
client_id: clientId
client_id: clientId,
// #415: tag the play with the system playlist it came from
// (null for library / user-playlist / radio) so the server
// can advance that playlist's rotation.
source: player.queueSource ?? undefined
});
// The user may have moved on by the time the response arrives. Only
// adopt the id if we're still on the same track and still playing.