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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user