From 918db27fec1caa5d1c7e0bc9c9ce2b9c6f93098e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 14:18:44 -0400 Subject: [PATCH] feat(web): shared collection header, Play and Shuffle, and the centred page column (M494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CollectionHeader holds cover, title, facts, then Play and Shuffle, for album and playlist. The playlist page gains Play and Shuffle; it had neither. The cover uses the shared Cover component, so the playlist header also gets the fallback image. - PlayActions is the one Play and Shuffle pair: filled Play, outlined Shuffle, on album, playlist and artist pages (operator, 2026-10-08). The artist page's round icon button said Play but shuffled; Play now keeps the server's order, and Shuffle shuffles. - PageColumn is the playlist page's centred column, now used by album, playlist, Liked, History and Search → Tracks. - The playlist page builds its queue with playlistTrackToRef, the shared converter, in place of its own copy. Its copy skipped only removed tracks, so a missing file (#2527) could still be queued from there. - shuffled() in lib/utils is the one shuffle, replacing three private copies (album page, artist page, ArtistCard). - The album's refetch error now uses text-error-fg. `text-oxblood` is not a colour in the theme, so it never rendered. Co-Authored-By: Claude Opus 5.5 --- web/src/lib/components/ArtistCard.svelte | 12 +- .../lib/components/CollectionHeader.svelte | 60 ++++++ web/src/lib/components/HistoryRow.svelte | 10 +- web/src/lib/components/PageColumn.svelte | 14 ++ web/src/lib/components/PlayActions.svelte | 53 +++++ web/src/lib/utils/shuffle.ts | 14 ++ web/src/routes/albums/[id]/+page.svelte | 161 ++++++--------- web/src/routes/artists/[id]/+page.svelte | 42 ++-- web/src/routes/artists/[id]/artist.test.ts | 46 ++++- web/src/routes/library/history/+page.svelte | 5 +- web/src/routes/library/liked/+page.svelte | 5 +- web/src/routes/playlists/[id]/+page.svelte | 186 +++++++++--------- .../routes/playlists/[id]/playlist.test.ts | 41 +++- web/src/routes/search/tracks/+page.svelte | 5 +- 14 files changed, 418 insertions(+), 236 deletions(-) create mode 100644 web/src/lib/components/CollectionHeader.svelte create mode 100644 web/src/lib/components/PageColumn.svelte create mode 100644 web/src/lib/components/PlayActions.svelte create mode 100644 web/src/lib/utils/shuffle.ts diff --git a/web/src/lib/components/ArtistCard.svelte b/web/src/lib/components/ArtistCard.svelte index 1b321bd0..03ec5d9b 100644 --- a/web/src/lib/components/ArtistCard.svelte +++ b/web/src/lib/components/ArtistCard.svelte @@ -1,4 +1,5 @@ + +
+
+ +
+ +
+
+

{title}

+ {#if titleExtra}{@render titleExtra()}{/if} + {#if tools} +
{@render tools()}
+ {/if} +
+ + {#if children}{@render children()}{/if} + +
+ + {#if actions}{@render actions()}{/if} + +
+
+
diff --git a/web/src/lib/components/HistoryRow.svelte b/web/src/lib/components/HistoryRow.svelte index 15690739..70c365d2 100644 --- a/web/src/lib/components/HistoryRow.svelte +++ b/web/src/lib/components/HistoryRow.svelte @@ -3,6 +3,7 @@ import { playQueue } from '#lib/player/store.svelte.js'; import { coverUrl } from '#lib/media/covers.js'; import TrackListRow from './TrackListRow.svelte'; + import Cover from './Cover.svelte'; let { event } = $props<{ event: HistoryEvent }>(); @@ -50,12 +51,9 @@ onActivate={handleClick} > {#snippet lead()} - + + + {/snippet} {#snippet trailing()} diff --git a/web/src/lib/components/PageColumn.svelte b/web/src/lib/components/PageColumn.svelte new file mode 100644 index 00000000..907080f0 --- /dev/null +++ b/web/src/lib/components/PageColumn.svelte @@ -0,0 +1,14 @@ + + +
+ {@render children()} +
diff --git a/web/src/lib/components/PlayActions.svelte b/web/src/lib/components/PlayActions.svelte new file mode 100644 index 00000000..8d271421 --- /dev/null +++ b/web/src/lib/components/PlayActions.svelte @@ -0,0 +1,53 @@ + + +
+ + + {#if children}{@render children()}{/if} +
diff --git a/web/src/lib/utils/shuffle.ts b/web/src/lib/utils/shuffle.ts new file mode 100644 index 00000000..3f5e9e7e --- /dev/null +++ b/web/src/lib/utils/shuffle.ts @@ -0,0 +1,14 @@ +/** + * A shuffled copy of `items` (Fisher–Yates). The input is not touched. + * + * The one copy for every Shuffle control: the album and playlist headers + * and the artist card's play button each had their own before this. + */ +export function shuffled(items: readonly T[]): T[] { + const arr = items.slice(); + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} diff --git a/web/src/routes/albums/[id]/+page.svelte b/web/src/routes/albums/[id]/+page.svelte index aa856e7b..264d8035 100644 --- a/web/src/routes/albums/[id]/+page.svelte +++ b/web/src/routes/albums/[id]/+page.svelte @@ -1,17 +1,18 @@ @@ -108,16 +103,15 @@ {/each} {/if} +
+ +
- {#if detail.albums.length === 0} diff --git a/web/src/routes/artists/[id]/artist.test.ts b/web/src/routes/artists/[id]/artist.test.ts index 31e38463..640348e2 100644 --- a/web/src/routes/artists/[id]/artist.test.ts +++ b/web/src/routes/artists/[id]/artist.test.ts @@ -1,8 +1,10 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import { flushSync } from 'svelte'; import { mockQuery } from '../../../test-utils/query'; import { emptyLikesMock } from '../../../test-utils/mocks/likes'; +import { apiClientMock } from '../../../test-utils/mocks/client'; +import { makeTracks } from '#test-utils/fixtures/track.js'; import type { ArtistDetail, AlbumRef } from '#lib/api/types.js'; const state = vi.hoisted(() => ({ pageParams: { id: 'abc' } as Record })); @@ -23,7 +25,20 @@ vi.mock('#lib/api/queries.js', () => ({ vi.mock('#lib/api/likes.js', () => emptyLikesMock()); +vi.mock('#lib/api/client.js', () => apiClientMock()); + +vi.mock('#lib/player/store.svelte.js', () => ({ + playQueue: vi.fn(), + enqueueTrack: vi.fn(), + enqueueTracks: vi.fn(), + playRadio: vi.fn(), + playNext: vi.fn(), + player: { current: undefined } +})); + import ArtistPage from './+page.svelte'; +import { api } from '#lib/api/client.js'; +import { playQueue } from '#lib/player/store.svelte.js'; import { createArtistQuery, createSimilarArtistsQuery, @@ -132,4 +147,33 @@ describe('artist detail page', () => { vi.useRealTimers(); } }); + + // Play and Shuffle are the shared PlayActions (milestone 494). Play keeps + // the server's order; before, the artist's only button was "Play" and it + // shuffled. + describe('Play and Shuffle', () => { + const detail: ArtistDetail = { + id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '', + albums: [album('a1', 'First', 2020)], genres: [] + }; + const tracks = makeTracks(5); + + test('Play queues the artist in the server order', async () => { + (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); + (api.get as ReturnType).mockResolvedValue(tracks); + render(ArtistPage); + await fireEvent.click(screen.getByRole('button', { name: 'Play Alice' })); + await waitFor(() => expect(playQueue).toHaveBeenCalledWith(tracks, 0)); + }); + + test('Shuffle queues every track', async () => { + (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); + (api.get as ReturnType).mockResolvedValue(tracks); + render(ArtistPage); + await fireEvent.click(screen.getByRole('button', { name: 'Shuffle Alice' })); + await waitFor(() => expect(playQueue).toHaveBeenCalled()); + const [queued] = (playQueue as ReturnType).mock.calls[0]; + expect(queued.map((t: { id: string }) => t.id).sort()).toEqual(tracks.map((t) => t.id).sort()); + }); + }); }); diff --git a/web/src/routes/library/history/+page.svelte b/web/src/routes/library/history/+page.svelte index 5622c4ba..44893c19 100644 --- a/web/src/routes/library/history/+page.svelte +++ b/web/src/routes/library/history/+page.svelte @@ -1,4 +1,5 @@