+ {/snippet}
+
diff --git a/web/src/lib/components/queue-row-math.ts b/web/src/lib/components/queue-row-math.ts
index 44cfa154..a1b06ef1 100644
--- a/web/src/lib/components/queue-row-math.ts
+++ b/web/src/lib/components/queue-row-math.ts
@@ -1,7 +1,8 @@
// Translate a vertical drag offset (px) into an integer row-delta,
-// given the measured row height (px). Used by QueueTrackRow when a
-// neodrag drop completes — we round to the nearest row boundary so
-// the user's drop intent maps to a clean queue index.
+// given the measured row height (px). Used by reorderDrag both to snap
+// the row while it is dragged and to report where it was dropped — it
+// rounds to the nearest row boundary, so the slot the row shows is the
+// slot it lands in.
export function offsetToDelta(offsetY: number, rowHeight: number): number {
if (rowHeight <= 0) return 0;
// `|| 0` normalizes -0 to +0. Math.round(-0.5) === -0; downstream
diff --git a/web/src/lib/components/reorder-drag.test.ts b/web/src/lib/components/reorder-drag.test.ts
new file mode 100644
index 00000000..a4660d66
--- /dev/null
+++ b/web/src/lib/components/reorder-drag.test.ts
@@ -0,0 +1,85 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { reorderDrag } from './reorder-drag';
+
+// jsdom lays nothing out, so the row and its list get explicit geometry:
+// a 560px list of 56px rows. neodrag divides by offsetWidth / rect.width
+// to undo CSS scaling, so offsetWidth has to agree with the rect.
+function rect(height: number): DOMRect {
+ return {
+ top: 0, left: 0, right: 300, bottom: height, width: 300, height,
+ x: 0, y: 0, toJSON: () => ({})
+ } as DOMRect;
+}
+
+function mount() {
+ const list = document.createElement('div');
+ const row = document.createElement('div');
+ const handle = document.createElement('button');
+ handle.setAttribute('data-reorder-handle', '');
+ row.appendChild(handle);
+ list.appendChild(row);
+ document.body.appendChild(list);
+ list.getBoundingClientRect = () => rect(560);
+ row.getBoundingClientRect = () => rect(56);
+ Object.defineProperty(row, 'offsetWidth', { value: 300 });
+ return { list, row, handle };
+}
+
+function pointer(target: EventTarget, type: string, clientY: number) {
+ target.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX: 10, clientY, button: 0 }));
+}
+
+afterEach(() => {
+ document.body.innerHTML = '';
+});
+
+describe('reorderDrag', () => {
+ test('a drag past half a row moves one slot, and the row lands back in place', () => {
+ const { row } = mount();
+ const onMove = vi.fn();
+ const action = reorderDrag(row, { onMove });
+
+ pointer(row, 'pointerdown', 10);
+ pointer(row, 'pointermove', 40);
+ // 30px of a 56px row snaps to the next slot while dragging.
+ expect(row.style.transform).toContain('56px');
+ pointer(row, 'pointerup', 40);
+
+ expect(onMove).toHaveBeenCalledWith(1);
+ expect(row.style.transform).not.toContain('56px');
+ action.destroy();
+ });
+
+ test('a drag of under half a row stays in its slot and reports nothing', () => {
+ const { row } = mount();
+ const onMove = vi.fn();
+ const action = reorderDrag(row, { onMove });
+
+ pointer(row, 'pointerdown', 10);
+ pointer(row, 'pointermove', 30);
+ pointer(row, 'pointerup', 30);
+
+ expect(onMove).not.toHaveBeenCalled();
+ action.destroy();
+ });
+
+ test('with a handle, only the handle starts a drag and takes the touch gesture', () => {
+ const { row, handle } = mount();
+ const onMove = vi.fn();
+ const action = reorderDrag(row, { handle: '[data-reorder-handle]', onMove });
+
+ expect(row.style.touchAction).toBe('');
+ expect(handle.style.touchAction).toBe('none');
+
+ pointer(row, 'pointerdown', 10);
+ pointer(row, 'pointermove', 80);
+ pointer(row, 'pointerup', 80);
+ expect(onMove).not.toHaveBeenCalled();
+
+ pointer(handle, 'pointerdown', 10);
+ pointer(handle, 'pointermove', 80);
+ pointer(handle, 'pointerup', 80);
+ expect(onMove).toHaveBeenCalledWith(1);
+ action.destroy();
+ });
+});
diff --git a/web/src/lib/components/reorder-drag.ts b/web/src/lib/components/reorder-drag.ts
new file mode 100644
index 00000000..bbb168e1
--- /dev/null
+++ b/web/src/lib/components/reorder-drag.ts
@@ -0,0 +1,98 @@
+// Drag-to-reorder for a row in a vertical list, shared by the playlist and
+// queue rows (#5393). Wraps @neodrag/svelte with the three things a list
+// needs that neodrag does not do on its own:
+//
+// - The row moves in whole-row steps, so it visibly snaps into the slot it
+// will land in. neodrag's own `grid` rounds with Math.ceil, which jumps a
+// full row down on a 1px drag but needs a full row to move up, so the
+// snapping is done here in `transform` with offsetToDelta's rounding.
+// - The row goes back to translate 0 on drop. Left uncontrolled, neodrag
+// keeps the drop offset, and once the list re-renders in its new order
+// that offset leaves the row sitting between two slots.
+// - Only the handle takes the touch gesture. neodrag sets touch-action:none
+// on the whole node, which stops a touch screen scrolling the list from
+// any row it is dragged from; with a handle, the row scrolls normally.
+
+import { draggable, type DragOptions } from '@neodrag/svelte';
+import { offsetToDelta } from './queue-row-math';
+
+export type ReorderDragOptions = {
+ disabled?: boolean;
+ /** Selector for the drag handle inside the row. Omit to drag from anywhere. */
+ handle?: string;
+ /** Called on drop with the whole number of rows moved (never 0). */
+ onMove: (delta: number) => void;
+};
+
+const FALLBACK_ROW_HEIGHT = 56;
+
+export function reorderDrag(node: HTMLElement, initial: ReorderDragOptions | null) {
+ let opts = initial;
+ let rowHeight = FALLBACK_ROW_HEIGHT;
+ let instance: ReturnType | null = null;
+
+ function handleEl(): HTMLElement | undefined {
+ if (!opts?.handle) return undefined;
+ return node.querySelector(opts.handle) ?? undefined;
+ }
+
+ function build(): DragOptions {
+ return {
+ axis: 'y',
+ bounds: 'parent',
+ disabled: opts?.disabled ?? false,
+ handle: handleEl(),
+ // Controlled at {0, 0}: every update() puts the row back in its slot.
+ position: { x: 0, y: 0 },
+ transform: ({ offsetY }) =>
+ `translate3d(0, ${offsetToDelta(offsetY, rowHeight) * rowHeight}px, 0)`,
+ onDragStart: ({ rootNode }) => {
+ const h = rootNode.getBoundingClientRect().height;
+ if (h > 0) rowHeight = h;
+ },
+ onDragEnd: ({ offsetY }) => {
+ const delta = offsetToDelta(offsetY, rowHeight);
+ instance?.update?.(build());
+ if (delta !== 0) opts?.onMove(delta);
+ }
+ };
+ }
+
+ function touchToHandle() {
+ const handle = handleEl();
+ if (!handle) return;
+ node.style.touchAction = '';
+ handle.style.touchAction = 'none';
+ }
+
+ function attach() {
+ if (!opts || instance) return;
+ instance = draggable(node, build());
+ touchToHandle();
+ }
+
+ attach();
+
+ return {
+ update(next: ReorderDragOptions | null) {
+ opts = next;
+ if (!opts) {
+ instance?.destroy?.();
+ instance = null;
+ node.style.touchAction = '';
+ node.style.transform = '';
+ return;
+ }
+ if (instance) {
+ instance.update?.(build());
+ touchToHandle();
+ } else {
+ attach();
+ }
+ },
+ destroy() {
+ instance?.destroy?.();
+ instance = null;
+ }
+ };
+}
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 72625ea1..264d8035 100644
--- a/web/src/routes/albums/[id]/+page.svelte
+++ b/web/src/routes/albums/[id]/+page.svelte
@@ -1,16 +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 cc417bfc..44893c19 100644
--- a/web/src/routes/library/history/+page.svelte
+++ b/web/src/routes/library/history/+page.svelte
@@ -1,8 +1,10 @@