feat(playlists): system playlists default to shuffle on tile play

Closes Fable #412 (For You force-refresh on play) and #413
(shuffle-on-play default for system playlists).

Web (PlaylistCard.svelte + player/store.svelte.ts):
- Drop the refreshForYou() call from the play handler. The daily
  03:00 user-local snapshot is what plays now. Stops burning server
  compute on every press and stops swapping the playlist out from
  under the user.
- Generalize the kebab affordance to render for any system playlist
  (was Discover-only). Adds "Refresh For You" as an explicit
  replacement so users can still force a regen when they want one.
- Extend playQueue(tracks, startIndex, { shuffle? }) to Fisher-Yates
  the queue when shuffle:true. PlaylistCard passes shuffle:true for
  any non-null system_variant.

Flutter (player_provider.dart + playlists/widgets/playlist_card.dart):
- playTracks now accepts shuffle:bool. When true, picks a random
  starting index and enables AudioServiceShuffleMode.all after
  setQueueFromTracks. PlaylistCard passes shuffle:playlist.isSystem.

User playlists keep linear order. Detail-screen play buttons are
unchanged for now (follow-up if user requests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:55:57 -04:00
parent 33b11a3b3d
commit d12afdad6e
5 changed files with 124 additions and 83 deletions
+49 -51
View File
@@ -143,7 +143,7 @@ describe('PlaylistCard', () => {
expect(playQueue).toHaveBeenCalled();
});
test('shows kebab button only on Discover playlists', () => {
test('shows kebab button on Discover playlists', () => {
const discoverPlaylist: Playlist = {
...base,
kind: 'system',
@@ -154,7 +154,7 @@ describe('PlaylistCard', () => {
expect(screen.getByLabelText(/playlist actions/i)).toBeInTheDocument();
});
test('does not show kebab button on for_you playlists', () => {
test('shows kebab button on For You playlists', () => {
const forYouPlaylist: Playlist = {
...base,
kind: 'system',
@@ -162,7 +162,7 @@ describe('PlaylistCard', () => {
name: 'For You'
};
render(PlaylistCard, { props: { playlist: forYouPlaylist } });
expect(screen.queryByLabelText(/playlist actions/i)).not.toBeInTheDocument();
expect(screen.getByLabelText(/playlist actions/i)).toBeInTheDocument();
});
test('does not show kebab button on user playlists', () => {
@@ -170,7 +170,7 @@ describe('PlaylistCard', () => {
expect(screen.queryByLabelText(/playlist actions/i)).not.toBeInTheDocument();
});
test('Refresh Discover item appears in kebab menu when opened', async () => {
test('Refresh Discover item appears in Discover kebab', async () => {
const discoverPlaylist: Playlist = {
...base,
kind: 'system',
@@ -183,7 +183,7 @@ describe('PlaylistCard', () => {
expect(screen.getByRole('menuitem', { name: /refresh discover/i })).toBeInTheDocument();
});
test('Refresh Discover item is absent from for_you card kebab', () => {
test('Refresh For You item appears in For You kebab', async () => {
const forYouPlaylist: Playlist = {
...base,
kind: 'system',
@@ -191,7 +191,9 @@ describe('PlaylistCard', () => {
name: 'For You'
};
render(PlaylistCard, { props: { playlist: forYouPlaylist } });
expect(screen.queryByRole('menuitem', { name: /refresh discover/i })).not.toBeInTheDocument();
const kebabBtn = screen.getByLabelText(/playlist actions/i);
await fireEvent.click(kebabBtn);
expect(screen.getByRole('menuitem', { name: /refresh for you/i })).toBeInTheDocument();
});
test('clicking Refresh Discover calls refreshDiscover', async () => {
@@ -210,7 +212,23 @@ describe('PlaylistCard', () => {
await waitFor(() => expect(refreshDiscover).toHaveBeenCalled());
});
test('For-You play button calls refreshForYou before getPlaylist', async () => {
test('clicking Refresh For You calls refreshForYou', async () => {
const { refreshForYou } = await import('$lib/api/playlists');
const forYouPlaylist: Playlist = {
...base,
kind: 'system',
system_variant: 'for_you',
name: 'For You'
};
render(PlaylistCard, { props: { playlist: forYouPlaylist } });
const kebabBtn = screen.getByLabelText(/playlist actions/i);
await fireEvent.click(kebabBtn);
const refreshItem = screen.getByRole('menuitem', { name: /refresh for you/i });
await fireEvent.click(refreshItem);
await waitFor(() => expect(refreshForYou).toHaveBeenCalled());
});
test('For-You play button does NOT call refreshForYou (uses existing snapshot)', async () => {
const { refreshForYou, getPlaylist } = await import('$lib/api/playlists');
const { playQueue } = await import('$lib/player/store.svelte');
const forYouPlaylist: Playlist = {
@@ -220,70 +238,50 @@ describe('PlaylistCard', () => {
name: 'For You',
track_count: 25
};
// getPlaylist should return a detail with the NEW id
(getPlaylist as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: 'p-new',
user_id: 'u-self',
owner_username: 'me',
name: 'For You',
description: '',
is_public: false,
kind: 'system',
system_variant: 'for_you',
seed_artist_id: null,
cover_url: '',
track_count: 1,
duration_sec: 60,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
tracks: [
{
position: 0,
track_id: 't-1',
album_id: 'al-1',
artist_id: 'ar-1',
title: 'Test Track',
artist_name: 'Test Artist',
album_title: 'Test Album',
duration_sec: 60,
stream_url: '/s/t-1',
added_at: '2026-01-01T00:00:00Z'
}
]
} as PlaylistDetail);
render(PlaylistCard, { props: { playlist: forYouPlaylist } });
const playButton = screen.getByLabelText(/Play For You/i);
await fireEvent.click(playButton);
await new Promise(resolve => setTimeout(resolve, 10));
expect(refreshForYou).toHaveBeenCalled();
expect(getPlaylist).toHaveBeenCalledWith('p-new');
// Play uses the existing playlist snapshot — no refresh on press.
expect(refreshForYou).not.toHaveBeenCalled();
expect(getPlaylist).toHaveBeenCalledWith('p-1');
expect(playQueue).toHaveBeenCalled();
});
test('For-You play with empty-library response does not call playQueue', async () => {
const { refreshForYou } = await import('$lib/api/playlists');
test('For-You play passes shuffle:true to playQueue', async () => {
const { playQueue } = await import('$lib/player/store.svelte');
(refreshForYou as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
playlist_id: null,
track_count: 0,
track_ids: []
});
const forYouPlaylist: Playlist = {
...base,
kind: 'system',
system_variant: 'for_you',
name: 'For You',
track_count: 1
name: 'For You'
};
render(PlaylistCard, { props: { playlist: forYouPlaylist } });
const playButton = screen.getByLabelText(/Play For You/i);
await fireEvent.click(playButton);
await new Promise(resolve => setTimeout(resolve, 10));
expect(refreshForYou).toHaveBeenCalled();
expect(playQueue).not.toHaveBeenCalled();
expect(playQueue).toHaveBeenCalledWith(
expect.any(Array),
0,
expect.objectContaining({ shuffle: true })
);
});
test('user-playlist play passes shuffle:false to playQueue', async () => {
const { playQueue } = await import('$lib/player/store.svelte');
render(PlaylistCard, { props: { playlist: base } });
const playButton = screen.getByLabelText(/Play Saturday morning/i);
await fireEvent.click(playButton);
await new Promise(resolve => setTimeout(resolve, 10));
expect(playQueue).toHaveBeenCalledWith(
expect.any(Array),
0,
expect.objectContaining({ shuffle: false })
);
});
test('non-For-You play button does NOT call refreshForYou', async () => {