feat(web/playlists): For-You tile play button refreshes and plays
Click on the For-You tile's play button now triggers a synchronous refresh, then enqueues + auto-plays the freshly-built playlist. Two-click-target tile pattern: tile BODY click still navigates to the playlist detail page (existing behavior); the play button is the new generate-and-play action. Behavior is gated on playlist.system_variant === 'for_you' — every other playlist (user, Discover, Songs-like-X) keeps the existing "fetch detail, enqueue, play" flow. The atomic-replace BuildSystem- Playlists creates a new playlist_id, so the play handler uses the refresh response's id for the follow-up getPlaylist call rather than the stale tile prop's id. Empty-library response (playlist_id: null) is a no-op — clicking play in a degenerate-empty library doesn't error, it just doesn't start playback. Caches are invalidated post-refresh so the home view re-fetches and the tile re-renders with the new id, avoiding the corner case of a click-then-tile-body 404. Tests cover the refresh-and-play happy path, the empty-library no-op, and that non-For-You playlists keep the existing flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import type { Playlist, PlaylistTrack, TrackRef } from '$lib/api/types';
|
||||
import { user } from '$lib/auth/store.svelte';
|
||||
import { getPlaylist, refreshDiscover } from '$lib/api/playlists';
|
||||
import { getPlaylist, refreshDiscover, refreshForYou } from '$lib/api/playlists';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { playQueue } from '$lib/player/store.svelte';
|
||||
|
||||
@@ -60,7 +60,22 @@
|
||||
if (starting) return;
|
||||
starting = true;
|
||||
try {
|
||||
const detail = await getPlaylist(playlist.id);
|
||||
let detailID = playlist.id;
|
||||
// For-You: refresh first so click means "give me a fresh mix
|
||||
// and play it." Atomic replace creates a new playlist row, so
|
||||
// the response's playlist_id is what we follow up with.
|
||||
if (playlist.system_variant === 'for_you') {
|
||||
const refreshed = await refreshForYou();
|
||||
if (!refreshed.playlist_id) {
|
||||
// Empty library — nothing to play.
|
||||
return;
|
||||
}
|
||||
detailID = refreshed.playlist_id;
|
||||
// Invalidate caches so the home + detail views see the new
|
||||
// playlist on their next read.
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||||
}
|
||||
const detail = await getPlaylist(detailID);
|
||||
const refs = toTrackRefs(detail.tracks);
|
||||
if (refs.length > 0) {
|
||||
playQueue(refs, 0);
|
||||
|
||||
@@ -46,7 +46,8 @@ vi.mock('$lib/api/playlists', () => ({
|
||||
}
|
||||
]
|
||||
} as PlaylistDetail),
|
||||
refreshDiscover: vi.fn().mockResolvedValue({ playlist_id: 'p-1', track_count: 5 })
|
||||
refreshDiscover: vi.fn().mockResolvedValue({ playlist_id: 'p-1', track_count: 5 }),
|
||||
refreshForYou: vi.fn().mockResolvedValue({ playlist_id: 'p-new', track_count: 25, track_ids: ['t-1'] })
|
||||
}));
|
||||
|
||||
vi.mock('$lib/player/store.svelte', () => ({
|
||||
@@ -216,4 +217,94 @@ describe('PlaylistCard', () => {
|
||||
await fireEvent.click(refreshItem);
|
||||
await waitFor(() => expect(refreshDiscover).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test('For-You play button calls refreshForYou before getPlaylist', async () => {
|
||||
const { refreshForYou, getPlaylist } = await import('$lib/api/playlists');
|
||||
const { playQueue } = await import('$lib/player/store.svelte');
|
||||
const forYouPlaylist: Playlist = {
|
||||
...base,
|
||||
kind: 'system',
|
||||
system_variant: 'for_you',
|
||||
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');
|
||||
expect(playQueue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('For-You play with empty-library response does not call playQueue', async () => {
|
||||
const { refreshForYou } = await import('$lib/api/playlists');
|
||||
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
|
||||
};
|
||||
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();
|
||||
});
|
||||
|
||||
test('non-For-You play button does NOT call refreshForYou', async () => {
|
||||
const { refreshForYou, getPlaylist } = await import('$lib/api/playlists');
|
||||
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(refreshForYou).not.toHaveBeenCalled();
|
||||
expect(getPlaylist).toHaveBeenCalledWith('p-1');
|
||||
expect(playQueue).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user