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 @@