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:
@@ -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.
|
||||
|
||||
@@ -41,6 +41,13 @@ let _pendingRestorePosition: number | null = null;
|
||||
let _radioSeedId = $state<string | null>(null);
|
||||
let _radioRefreshInFlight = false;
|
||||
|
||||
// #415: which system playlist (if any) this queue was seeded from.
|
||||
// 'for_you' | 'discover' | null. Read by the events dispatcher so
|
||||
// play_started carries `source` and the play counts against that
|
||||
// playlist's rotation. Re-set on every playQueue (a fresh non-system
|
||||
// queue clears it).
|
||||
let _queueSource = $state<string | null>(null);
|
||||
|
||||
let _audioEl: HTMLAudioElement | null = null;
|
||||
|
||||
export const player = {
|
||||
@@ -55,6 +62,7 @@ export const player = {
|
||||
get shuffle() { return _shuffle; },
|
||||
get repeat() { return _repeat; },
|
||||
get error() { return _error; },
|
||||
get queueSource() { return _queueSource; },
|
||||
get queueDrawerOpen() { return _queueDrawerOpen; }
|
||||
};
|
||||
|
||||
@@ -65,9 +73,13 @@ export function registerAudioEl(el: HTMLAudioElement | null): void {
|
||||
export function playQueue(
|
||||
tracks: TrackRef[],
|
||||
startIndex = 0,
|
||||
opts: { shuffle?: boolean } = {},
|
||||
opts: { shuffle?: boolean; source?: string | null } = {},
|
||||
): void {
|
||||
_radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state
|
||||
// #415: a fresh queue resets the system-playlist source. Set only
|
||||
// when seeded from a system playlist (server already returned the
|
||||
// rotation-aware order, so no client shuffle in that path).
|
||||
_queueSource = opts.source ?? null;
|
||||
if (opts.shuffle && tracks.length > 1) {
|
||||
// Fisher-Yates over the whole list. startIndex is ignored — the
|
||||
// caller is asking for "random play from this pool," so the first
|
||||
|
||||
Reference in New Issue
Block a user