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 e2e9803d..70c365d2 100644 --- a/web/src/lib/components/HistoryRow.svelte +++ b/web/src/lib/components/HistoryRow.svelte @@ -2,6 +2,8 @@ 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'; + import Cover from './Cover.svelte'; let { event } = $props<{ event: HistoryEvent }>(); @@ -40,25 +42,23 @@ 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..a37f73ae 100644 --- a/web/src/lib/components/HistoryRow.test.ts +++ b/web/src/lib/components/HistoryRow.test.ts @@ -1,15 +1,35 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; 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()); + +// Imported after the mocks: the component pulls in LikeButton, whose +// mocked module must already be initialised. +import HistoryRow from './HistoryRow.svelte'; + const sampleTrack = makeTrack({ title: 'Song Title', album_title: 'Album', @@ -41,6 +61,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/HorizontalScrollRow.svelte b/web/src/lib/components/HorizontalScrollRow.svelte index a188af37..b1944227 100644 --- a/web/src/lib/components/HorizontalScrollRow.svelte +++ b/web/src/lib/components/HorizontalScrollRow.svelte @@ -62,14 +62,8 @@
{#if title} - -

{title}

+ +

{title}

{:else} {/if} 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/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/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 @@