diff --git a/web/src/lib/components/HistoryRow.svelte b/web/src/lib/components/HistoryRow.svelte
index e2e9803d..15690739 100644
--- a/web/src/lib/components/HistoryRow.svelte
+++ b/web/src/lib/components/HistoryRow.svelte
@@ -2,6 +2,7 @@
import type { HistoryEvent } from '#lib/api/history.js';
import { playQueue } from '#lib/player/store.svelte.js';
import { coverUrl } from '#lib/media/covers.js';
+ import TrackListRow from './TrackListRow.svelte';
let { event } = $props<{ event: HistoryEvent }>();
@@ -40,25 +41,26 @@
const tooltip = $derived(new Date(event.played_at).toISOString());
-
+ {#snippet lead()}
+
+ {/snippet}
+
+ {#snippet trailing()}
+
+ {timestamp}
+
+ {/snippet}
+
diff --git a/web/src/lib/components/HistoryRow.test.ts b/web/src/lib/components/HistoryRow.test.ts
index 52145e0c..5dfcbdaf 100644
--- a/web/src/lib/components/HistoryRow.test.ts
+++ b/web/src/lib/components/HistoryRow.test.ts
@@ -3,13 +3,30 @@ import { render, screen, fireEvent } from '@testing-library/svelte';
import HistoryRow from './HistoryRow.svelte';
import type { HistoryEvent } from '#lib/api/history.js';
import { makeTrack } from '#test-utils/fixtures/track.js';
+import { emptyLikesMock } from '../../test-utils/mocks/likes';
+import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
const playQueue = vi.fn();
+// The row is a TrackListRow, so LikeButton and TrackMenu render too.
vi.mock('#lib/player/store.svelte.js', () => ({
- playQueue: (...args: unknown[]) => playQueue(...args)
+ playQueue: (...args: unknown[]) => playQueue(...args),
+ playNext: vi.fn(),
+ enqueueTrack: vi.fn(),
+ playRadio: vi.fn(),
+ player: { current: undefined }
}));
+vi.mock('#lib/auth/store.svelte.js', () => ({
+ user: { value: { id: 'u1', username: 'me', is_admin: false } }
+}));
+
+vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
+
+vi.mock('#lib/api/likes.js', () => emptyLikesMock());
+
+vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
+
const sampleTrack = makeTrack({
title: 'Song Title',
album_title: 'Album',
@@ -41,6 +58,13 @@ describe('HistoryRow', () => {
expect(playQueue).toHaveBeenCalledWith([sampleTrack], 0);
});
+ it('carries the shared row controls: like, radio, add and the menu', () => {
+ render(HistoryRow, { props: { event: sampleEvent } });
+ expect(screen.getByRole('button', { name: /play radio/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /add song title to queue/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /track actions for/i })).toBeInTheDocument();
+ });
+
it('renders a relative timestamp for events under 1 hour ago', () => {
render(HistoryRow, { props: { event: sampleEvent } });
expect(screen.getByText(/\d+m ago/)).toBeInTheDocument();
diff --git a/web/src/lib/components/PlaylistTrackRow.svelte b/web/src/lib/components/PlaylistTrackRow.svelte
index a2b5271e..cddf0c4b 100644
--- a/web/src/lib/components/PlaylistTrackRow.svelte
+++ b/web/src/lib/components/PlaylistTrackRow.svelte
@@ -1,12 +1,10 @@
-
onPlay(row.position)}
>
- {#if isOwner}
-
- {/if}
+ {#snippet lead()}
+ {#if isOwner}
+
+ {/if}
+ {/snippet}
-
+ {#snippet note()}
+ {#if isMissingFile}
+
· File missing
+ {/if}
+ {/snippet}
-
{format(row.duration_sec)}
-
- {#if !isUnavailable && liveTrack}
-
-
- {/if}
-
- {#if isOwner}
-
- {/if}
-
+ {#snippet trailing()}
+ {#if isOwner}
+
+ {/if}
+ {/snippet}
+
diff --git a/web/src/lib/components/QueueTrackRow.svelte b/web/src/lib/components/QueueTrackRow.svelte
index 17c5e3a7..edcb1521 100644
--- a/web/src/lib/components/QueueTrackRow.svelte
+++ b/web/src/lib/components/QueueTrackRow.svelte
@@ -1,10 +1,9 @@
moveQueueItem(index, index + delta) }}
class="flex items-center gap-2 border-b border-border px-3 py-2 h-16
- {isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
+ [&.neodrag-dragging]:relative [&.neodrag-dragging]:z-10 [&.neodrag-dragging]:shadow-lg
+ {isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : 'bg-background'}"
>
+ {#snippet lead()}
+
{track.track_number ?? '—'}
-
-
-
{track.title}
-
-
-
-
-
{formatDuration(track.duration_sec)}
-
+ {/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/routes/albums/[id]/+page.svelte b/web/src/routes/albums/[id]/+page.svelte
index 72625ea1..aa856e7b 100644
--- a/web/src/routes/albums/[id]/+page.svelte
+++ b/web/src/routes/albums/[id]/+page.svelte
@@ -5,6 +5,7 @@
import { qk } from '#lib/api/queries.js';
import { pageTitle } from '#lib/branding.js';
import TrackRow from '#lib/components/TrackRow.svelte';
+ import TrackList from '#lib/components/TrackList.svelte';
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import LikeButton from '#lib/components/LikeButton.svelte';
@@ -199,11 +200,11 @@
{#if album.tracks.length === 0}
This album has no tracks.
{:else}
-
+
{#each album.tracks as track, i (track.id)}
-
+
{/each}
-
+
{/if}
{/if}
diff --git a/web/src/routes/library/history/+page.svelte b/web/src/routes/library/history/+page.svelte
index cc417bfc..5622c4ba 100644
--- a/web/src/routes/library/history/+page.svelte
+++ b/web/src/routes/library/history/+page.svelte
@@ -3,6 +3,7 @@
import { createHistoryQuery } from '#lib/api/history.js';
import { groupByDay } from '#lib/utils/dayGroup.js';
import HistoryRow from '#lib/components/HistoryRow.svelte';
+ import TrackList from '#lib/components/TrackList.svelte';
import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte';
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
@@ -62,11 +63,11 @@
{group.label}
-
+
{#each group.events as event (event.id)}
{/each}
-
+
{/each}
diff --git a/web/src/routes/library/history/page.test.ts b/web/src/routes/library/history/page.test.ts
index 550cf6e4..9777edcf 100644
--- a/web/src/routes/library/history/page.test.ts
+++ b/web/src/routes/library/history/page.test.ts
@@ -3,6 +3,8 @@ import { render, screen } from '@testing-library/svelte';
import HistoryPage from './+page.svelte';
import type { HistoryEvent, HistoryResponse } from '#lib/api/history.js';
import { makeTrack } from '#test-utils/fixtures/track.js';
+import { emptyLikesMock } from '../../../test-utils/mocks/likes';
+import { emptyQuarantineMock } from '../../../test-utils/mocks/quarantine';
const mkTrack = (id: string) => makeTrack({ id: `track-${id}`, title: `Song ${id}` });
@@ -44,10 +46,19 @@ vi.mock('#lib/api/history.js', async () => {
};
});
+// Rows are TrackListRows, so LikeButton and TrackMenu render too.
vi.mock('#lib/player/store.svelte.js', () => ({
- playQueue: vi.fn()
+ playQueue: vi.fn(),
+ playNext: vi.fn(),
+ enqueueTrack: vi.fn(),
+ playRadio: vi.fn(),
+ player: { current: undefined }
}));
+vi.mock('#lib/api/likes.js', () => emptyLikesMock());
+
+vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
+
vi.mock('#lib/branding.js', () => ({
pageTitle: (s: string) => `Minstrel · ${s}`
}));
diff --git a/web/src/routes/library/liked/+page.svelte b/web/src/routes/library/liked/+page.svelte
index 8339e46f..096cc0e9 100644
--- a/web/src/routes/library/liked/+page.svelte
+++ b/web/src/routes/library/liked/+page.svelte
@@ -8,6 +8,7 @@
import ArtistCard from '#lib/components/ArtistCard.svelte';
import AlbumCard from '#lib/components/AlbumCard.svelte';
import TrackRow from '#lib/components/TrackRow.svelte';
+ import TrackList from '#lib/components/TrackList.svelte';
import QuickFilter from '#lib/components/QuickFilter.svelte';
import EmptyState from '#lib/components/EmptyState.svelte';
@@ -157,11 +158,11 @@
{/snippet}
{:else}
-
+
{#each fTracks as t, i (t.id)}
{/each}
-
+
{#if tracksQuery?.hasNextPage}