One track-list shape across the web app, drag reorder that snaps (M494) #152

Merged
bvandeusen merged 4 commits from dev into main 2026-10-08 15:08:10 -04:00
28 changed files with 1088 additions and 481 deletions
+2 -10
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { shuffled } from '#lib/utils/shuffle.js';
import type { ArtistRef, TrackRef } from '#lib/api/types.js'; import type { ArtistRef, TrackRef } from '#lib/api/types.js';
import { api } from '#lib/api/client.js'; import { api } from '#lib/api/client.js';
import { playQueue, enqueueTracks } from '#lib/player/store.svelte.js'; import { playQueue, enqueueTracks } from '#lib/player/store.svelte.js';
@@ -12,21 +13,12 @@
let queueing = $state(false); let queueing = $state(false);
function shuffle<T>(items: 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;
}
async function onPlayClick(e: MouseEvent) { async function onPlayClick(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const tracks = await api.get<TrackRef[]>(`/api/artists/${artist.id}/tracks`); const tracks = await api.get<TrackRef[]>(`/api/artists/${artist.id}/tracks`);
if (tracks.length === 0) return; if (tracks.length === 0) return;
playQueue(shuffle(tracks), 0); playQueue(shuffled(tracks), 0);
} }
async function onAddToQueue(e: MouseEvent) { async function onAddToQueue(e: MouseEvent) {
@@ -0,0 +1,60 @@
<script lang="ts">
// The header of every page that is one collection of tracks, album and
// playlist (milestone 494): cover, title, a line or two of facts, then
// Play and Shuffle (PlayActions).
import type { Snippet } from 'svelte';
import PlayActions from './PlayActions.svelte';
import Cover from './Cover.svelte';
let {
title,
coverUrl,
coverAlt = '',
playDisabled = false,
onPlay,
onShuffle,
titleExtra,
tools,
children,
actions
}: {
title: string;
coverUrl: string | null | undefined;
coverAlt?: string;
playDisabled?: boolean;
onPlay: () => void;
onShuffle: () => void;
/** Beside the title, e.g. the album's like button. */
titleExtra?: Snippet;
/** Top right: refresh, link, edit, delete. */
tools?: Snippet;
/** The facts under the title. */
children?: Snippet;
/** After Play and Shuffle, e.g. an admin's Refetch cover. */
actions?: Snippet;
} = $props();
</script>
<header class="mb-6 flex items-start gap-4">
<div class="h-32 w-32 shrink-0 overflow-hidden rounded-md md:h-40 md:w-40">
<Cover src={coverUrl ?? undefined} alt={coverAlt} />
</div>
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-start gap-3">
<h1 class="min-w-0 truncate font-display text-2xl font-medium text-text-primary">{title}</h1>
{#if titleExtra}{@render titleExtra()}{/if}
{#if tools}
<div class="ml-auto flex shrink-0 items-center gap-1">{@render tools()}</div>
{/if}
</div>
{#if children}{@render children()}{/if}
<div class="pt-1">
<PlayActions name={title} disabled={playDisabled} {onPlay} {onShuffle}>
{#if actions}{@render actions()}{/if}
</PlayActions>
</div>
</div>
</header>
+19 -19
View File
@@ -2,6 +2,8 @@
import type { HistoryEvent } from '#lib/api/history.js'; import type { HistoryEvent } from '#lib/api/history.js';
import { playQueue } from '#lib/player/store.svelte.js'; import { playQueue } from '#lib/player/store.svelte.js';
import { coverUrl } from '#lib/media/covers.js'; import { coverUrl } from '#lib/media/covers.js';
import TrackListRow from './TrackListRow.svelte';
import Cover from './Cover.svelte';
let { event } = $props<{ event: HistoryEvent }>(); let { event } = $props<{ event: HistoryEvent }>();
@@ -40,25 +42,23 @@
const tooltip = $derived(new Date(event.played_at).toISOString()); const tooltip = $derived(new Date(event.played_at).toISOString());
</script> </script>
<button <TrackListRow
type="button" title={event.track.title}
onclick={handleClick} subtitle={`${event.track.artist_name} · ${event.track.album_title}`}
aria-label={`Play ${event.track.title}`} durationSec={event.track.duration_sec}
class="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-surface-hover" track={event.track}
ariaLabel={`Play ${event.track.title}`}
onActivate={handleClick}
> >
<img {#snippet lead()}
src={coverUrl(event.track.album_id)} <span class="block h-8 w-8 overflow-hidden rounded">
alt="" <Cover src={coverUrl(event.track.album_id)} />
class="h-11 w-11 shrink-0 rounded object-cover" </span>
loading="lazy" {/snippet}
/>
<div class="min-w-0 flex-1"> {#snippet trailing()}
<div class="truncate text-sm font-medium text-text-primary">{event.track.title}</div> <span title={tooltip} class="w-16 shrink-0 text-right text-xs text-text-secondary">
<div class="truncate text-xs text-text-secondary">
{event.track.artist_name} · {event.track.album_title}
</div>
</div>
<span title={tooltip} class="shrink-0 text-xs text-text-secondary">
{timestamp} {timestamp}
</span> </span>
</button> {/snippet}
</TrackListRow>
+29 -2
View File
@@ -1,15 +1,35 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte'; import { render, screen, fireEvent } from '@testing-library/svelte';
import HistoryRow from './HistoryRow.svelte';
import type { HistoryEvent } from '#lib/api/history.js'; import type { HistoryEvent } from '#lib/api/history.js';
import { makeTrack } from '#test-utils/fixtures/track.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(); const playQueue = vi.fn();
// The row is a TrackListRow, so LikeButton and TrackMenu render too.
vi.mock('#lib/player/store.svelte.js', () => ({ 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({ const sampleTrack = makeTrack({
title: 'Song Title', title: 'Song Title',
album_title: 'Album', album_title: 'Album',
@@ -41,6 +61,13 @@ describe('HistoryRow', () => {
expect(playQueue).toHaveBeenCalledWith([sampleTrack], 0); 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', () => { it('renders a relative timestamp for events under 1 hour ago', () => {
render(HistoryRow, { props: { event: sampleEvent } }); render(HistoryRow, { props: { event: sampleEvent } });
expect(screen.getByText(/\d+m ago/)).toBeInTheDocument(); expect(screen.getByText(/\d+m ago/)).toBeInTheDocument();
@@ -62,14 +62,8 @@
<div class="section" aria-label={ariaLabel}> <div class="section" aria-label={ariaLabel}>
<header class="section-header"> <header class="section-header">
{#if title} {#if title}
<!-- Section header: Fraunces (font-display) at 24px with an <!-- Section header: Fraunces (font-display) at 24px. -->
inline accent rule that bleeds out from the title — gives <h2 class="font-display text-2xl font-medium text-text-primary">{title}</h2>
each shelf a clear chapter break without adding chrome. -->
<h2
class="font-display text-2xl font-medium text-text-primary
flex items-baseline gap-3 after:h-[2px] after:w-12 after:shrink-0
after:rounded-full after:bg-accent/60 after:content-['']"
>{title}</h2>
{:else} {:else}
<span></span> <span></span>
{/if} {/if}
+14
View File
@@ -0,0 +1,14 @@
<script lang="ts">
// The centred column every track-list page sits in: album, playlist,
// Liked, History and Search → Tracks (milestone 494). The playlist page
// had it alone from May 2026; the operator picked it as the page shape
// on 2026-10-08. Shelf and grid pages (Home, Library, Discover, Artist,
// Search) stay full width, since a narrow column would cost them cards.
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<div class="mx-auto w-full max-w-4xl space-y-4 px-4 py-6">
{@render children()}
</div>
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
// Play and Shuffle, the same on every page that has them: album,
// playlist and artist (milestone 494). Play is filled and Shuffle
// outlined. The fill is the accent: the operator's call on 2026-10-08,
// made with the house rule against accent on action buttons stated to
// them.
import type { Snippet } from 'svelte';
import { Play, Shuffle } from 'lucide-svelte';
let {
name,
disabled = false,
onPlay,
onShuffle,
children
}: {
/** What is played, for the buttons' accessible names. */
name: string;
disabled?: boolean;
onPlay: () => void;
onShuffle: () => void;
/** Further actions after Shuffle. */
children?: Snippet;
} = $props();
</script>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
aria-label={`Play ${name}`}
onclick={onPlay}
{disabled}
class="flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-medium
text-text-primary hover:opacity-90 focus-visible:ring-2 focus-visible:ring-accent
disabled:opacity-50"
>
<Play size={16} strokeWidth={1.5} fill="currentColor" />
Play
</button>
<button
type="button"
aria-label={`Shuffle ${name}`}
onclick={onShuffle}
{disabled}
class="flex items-center gap-2 rounded-md border border-border bg-surface px-4 py-2
text-sm font-medium text-text-primary hover:border-accent
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
>
<Shuffle size={16} strokeWidth={1.5} />
Shuffle
</button>
{#if children}{@render children()}{/if}
</div>
+41 -74
View File
@@ -1,12 +1,10 @@
<script lang="ts"> <script lang="ts">
// A track in a playlist. The look is TrackListRow's; what this adds is
// the reorder grip, remove, and the two ways an entry can be dead.
import { GripVertical, X } from 'lucide-svelte'; import { GripVertical, X } from 'lucide-svelte';
import { draggable, type DragEventData } from '@neodrag/svelte';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
import type { PlaylistTrack } from '#lib/api/types.js'; import type { PlaylistTrack } from '#lib/api/types.js';
import { playlistTrackToRef } from '#lib/playlists/playlistTrackToRef.js'; import { playlistTrackToRef } from '#lib/playlists/playlistTrackToRef.js';
import { player } from '#lib/player/store.svelte.js'; import TrackListRow from './TrackListRow.svelte';
import { offsetToDelta } from './queue-row-math';
let { let {
row, row,
@@ -32,22 +30,21 @@
const isRemoved = $derived(row.track_id === null); const isRemoved = $derived(row.track_id === null);
const isUnavailable = $derived(isRemoved || isMissingFile); const isUnavailable = $derived(isRemoved || isMissingFile);
// Reconstruct a minimal TrackRef for the kebab menu when the // null when the entry cannot be played, which hides like, radio, add
// upstream track still exists. When unavailable, the menu is hidden. // and the menu.
const liveTrack = $derived(playlistTrackToRef(row)); const liveTrack = $derived(playlistTrackToRef(row));
let measuredRowHeight = 56; // sensible default; replaced on dragStart const canDrag = $derived(isOwner && !isUnavailable);
function handleDragStart(data: DragEventData) { const drag = $derived(
const rect = data.rootNode.getBoundingClientRect(); isOwner
if (rect.height > 0) measuredRowHeight = rect.height; ? {
} disabled: !canDrag,
handle: '[data-reorder-handle]',
function handleDragEnd(data: DragEventData) { onMove: (delta: number) => onMove?.(row.position, row.position + delta)
const delta = offsetToDelta(data.offsetY, measuredRowHeight);
if (delta === 0) return;
onMove?.(row.position, row.position + delta);
} }
: null
);
function handleHandleKeydown(e: KeyboardEvent) { function handleHandleKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
@@ -63,87 +60,57 @@
e.preventDefault(); e.preventDefault();
} }
} }
function format(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
// Drag is gated on owner+available. When neither, the action runs but
// the dragEnd handler skips by returning before onMove fires; visually
// the user never sees the row move.
const canDrag = $derived(isOwner && !isUnavailable);
// "Now playing" highlight: matches QueueTrackRow's treatment so the
// user can see which row is current without reading the player bar.
// Unavailable rows (trackId === null) never match.
const isCurrent = $derived(
!isUnavailable && player.current?.id === row.track_id,
);
</script> </script>
<div <TrackListRow
role="listitem" title={row.title}
class="flex items-center gap-3 px-3 py-2 text-sm transition-colors hover:bg-surface-hover subtitle={`${row.artist_name} · ${row.album_title}`}
{isUnavailable ? 'text-text-muted' : 'text-text-primary'} durationSec={row.duration_sec}
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}" track={liveTrack}
use:draggable={{ ariaLabel={`Play ${row.title}`}
axis: 'y', rowTitle={isMissingFile
bounds: 'parent', ? 'This track’s file is missing from the library, so it can’t be played. It stays in the playlist, and returns automatically if the file comes back.'
disabled: !canDrag, : undefined}
onDragStart: handleDragStart, disabled={isUnavailable}
onDragEnd: handleDragEnd struck={isRemoved}
}} {drag}
onActivate={() => onPlay(row.position)}
> >
{#snippet lead()}
{#if isOwner} {#if isOwner}
<button <button
type="button" type="button"
data-reorder-handle
disabled={!canDrag} disabled={!canDrag}
aria-label="Reorder track (drag or use arrow keys)" aria-label="Reorder track (drag or use arrow keys)"
aria-keyshortcuts="ArrowUp ArrowDown" aria-keyshortcuts="ArrowUp ArrowDown"
onclick={(e) => e.stopPropagation()}
onkeydown={handleHandleKeydown} onkeydown={handleHandleKeydown}
class="shrink-0 cursor-grab text-text-muted hover:text-text-primary class="cursor-grab p-1 text-text-muted hover:text-text-primary
active:cursor-grabbing focus-visible:outline-solid focus-visible:outline-2 active:cursor-grabbing focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50" focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50"
> >
<GripVertical size={14} strokeWidth={1} /> <GripVertical size={16} strokeWidth={1} />
</button> </button>
{/if} {/if}
{/snippet}
<button {#snippet note()}
type="button"
class="min-w-0 flex-1 text-left"
onclick={() => !isUnavailable && onPlay(row.position)}
disabled={isUnavailable}
title={isMissingFile
? 'This track’s file is missing from the library, so it can’t be played. It stays in the playlist, and returns automatically if the file comes back.'
: undefined}
>
<div class="truncate {isRemoved ? 'line-through' : ''}">{row.title}</div>
<div class="truncate text-xs text-text-muted">
{row.artist_name} · {row.album_title}
{#if isMissingFile} {#if isMissingFile}
<span class="text-action-destructive"> · File missing</span> <span class="text-error-fg"> · File missing</span>
{/if}
</div>
</button>
<span class="shrink-0 text-xs text-text-muted">{format(row.duration_sec)}</span>
{#if !isUnavailable && liveTrack}
<LikeButton entityType="track" entityId={liveTrack.id} />
<TrackMenu track={liveTrack} />
{/if} {/if}
{/snippet}
{#snippet trailing()}
{#if isOwner} {#if isOwner}
<button <button
type="button" type="button"
onclick={() => onRemove(row.position)} onclick={(e) => { e.stopPropagation(); onRemove(row.position); }}
aria-label={`Remove ${row.title} from playlist`} aria-label={`Remove ${row.title} from playlist`}
class="shrink-0 rounded p-1 text-text-muted hover:bg-surface hover:text-action-destructive" class="shrink-0 rounded p-1 text-text-muted hover:bg-surface hover:text-action-destructive"
> >
<X size={14} strokeWidth={1} /> <X size={16} strokeWidth={1} />
</button> </button>
{/if} {/if}
</div> {/snippet}
</TrackListRow>
+5 -19
View File
@@ -1,10 +1,9 @@
<script lang="ts"> <script lang="ts">
import { GripVertical, X } from 'lucide-svelte'; import { GripVertical, X } from 'lucide-svelte';
import { draggable, type DragEventData } from '@neodrag/svelte';
import type { TrackRef } from '#lib/api/types.js'; import type { TrackRef } from '#lib/api/types.js';
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '#lib/player/store.svelte.js'; import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '#lib/player/store.svelte.js';
import { coverUrl, FALLBACK_COVER } from '#lib/media/covers.js'; import { coverUrl, FALLBACK_COVER } from '#lib/media/covers.js';
import { offsetToDelta } from './queue-row-math'; import { reorderDrag } from './reorder-drag';
import LikeButton from './LikeButton.svelte'; import LikeButton from './LikeButton.svelte';
let { track, index, isCurrent } = $props<{ let { track, index, isCurrent } = $props<{
@@ -13,14 +12,6 @@
isCurrent: boolean; isCurrent: boolean;
}>(); }>();
let measuredRowHeight = 64; // sensible default; replaced on dragStart
function handleDragStart(data: DragEventData) {
// The action's `data.rootNode` is the element use:draggable was applied to.
const rect = data.rootNode.getBoundingClientRect();
if (rect.height > 0) measuredRowHeight = rect.height;
}
function handleBodyClick() { function handleBodyClick() {
if (isCurrent) return; if (isCurrent) return;
playFromQueueIndex(index); playFromQueueIndex(index);
@@ -30,12 +21,6 @@
removeFromQueue(index); removeFromQueue(index);
} }
function handleDragEnd(data: DragEventData) {
const delta = offsetToDelta(data.offsetY, measuredRowHeight);
if (delta === 0) return;
moveQueueItem(index, index + delta);
}
function handleHandleKeydown(e: KeyboardEvent) { function handleHandleKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
e.preventDefault(); e.preventDefault();
@@ -53,14 +38,15 @@
</script> </script>
<div <div
use:draggable={{ axis: 'y', bounds: 'parent', onDragStart: handleDragStart, onDragEnd: handleDragEnd }} use:reorderDrag={{ onMove: (delta) => moveQueueItem(index, index + delta) }}
class="flex items-center gap-2 border-b border-border px-3 py-2 h-16 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'}"
> >
<!-- <!--
The album art is the grab surface (#2395). The grip used to occupy its own The album art is the grab surface (#2395). The grip used to occupy its own
column in every row; it now sits OVER the art, so it costs no horizontal column in every row; it now sits OVER the art, so it costs no horizontal
space at all. `use:draggable` is on the row (above), so dragging already space at all. `use:reorderDrag` is on the row (above), so dragging already
worked from anywhere — the grip's real jobs are being the visual cue and worked from anywhere — the grip's real jobs are being the visual cue and
the keyboard target, and both survive here. the keyboard target, and both survive here.
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
// The box every track list sits in (milestone 494). No fill of its own:
// the rows stripe themselves, and a filled box would hide the stripe.
// Rows are direct children so `odd:` counts them.
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<div class="overflow-hidden rounded-md border border-border">
{@render children()}
</div>
+151
View File
@@ -0,0 +1,151 @@
<script lang="ts">
// The one shape every track list in the web app draws (milestone 494):
// a lead column (track number, drag grip or cover), title over a muted
// subtitle, then like, radio, add-to-queue, the track menu and the
// duration, then whatever the list adds at the end (remove, a timestamp).
// The wrappers — TrackRow, PlaylistTrackRow, HistoryRow — own behaviour
// (selection, reorder, remove); this component owns how a row looks, so
// the lists cannot drift apart again.
import type { Snippet } from 'svelte';
import { Plus, Radio } from 'lucide-svelte';
import type { TrackRef } from '#lib/api/types.js';
import { formatDuration } from '#lib/media/duration.js';
import { player, enqueueTrack, playRadio } from '#lib/player/store.svelte.js';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
import { reorderDrag, type ReorderDragOptions } from './reorder-drag';
let {
title,
subtitle = '',
durationSec,
track,
ariaLabel,
rowTitle,
isSelected = false,
disabled = false,
struck = false,
drag = null,
onActivate,
lead,
note,
trailing
}: {
title: string;
subtitle?: string;
durationSec: number;
/** The playable track. null when the row cannot be played, which hides
like, radio, add and the menu: there is nothing for them to act on. */
track: TrackRef | null;
ariaLabel: string;
/** Tooltip for the whole row. */
rowTitle?: string;
isSelected?: boolean;
disabled?: boolean;
/** Strike the title through: the track is gone for good. */
struck?: boolean;
drag?: ReorderDragOptions | null;
onActivate: (e: MouseEvent | KeyboardEvent) => void;
lead?: Snippet;
/** Appended to the subtitle line, e.g. a "File missing" marker. */
note?: Snippet;
trailing?: Snippet;
} = $props();
const isCurrent = $derived(!!track && player.current?.id === track.id);
// The stripe is only drawn on rows with no state of their own. Tailwind
// emits `odd:` after the plain utilities, so a striped odd row would win
// over the now-playing or selected background.
const background = $derived(
isCurrent
? 'border-l-2 border-l-accent bg-surface-hover'
: isSelected
? 'bg-surface-hover'
: 'odd:bg-surface/50 hover:bg-surface-hover'
);
function onclick(e: MouseEvent) {
if (!disabled) onActivate(e);
}
function onkeydown(e: KeyboardEvent) {
// Keys pressed on a control inside the row belong to that control.
if (e.target !== e.currentTarget) return;
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
if (!disabled) onActivate(e);
}
function onRadio(e: MouseEvent) {
e.stopPropagation();
if (track) playRadio(track.id);
}
function onAdd(e: MouseEvent) {
e.stopPropagation();
if (track) enqueueTrack(track);
}
</script>
<div
role="button"
tabindex="0"
aria-label={ariaLabel}
aria-disabled={disabled || undefined}
title={rowTitle}
data-selected={isSelected}
{onclick}
{onkeydown}
use:reorderDrag={drag}
class="group relative flex w-full items-center gap-3 px-3 py-2 text-left text-sm
transition-colors focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent [&.neodrag-dragging]:z-10
[&.neodrag-dragging]:bg-surface-hover [&.neodrag-dragging]:shadow-lg
{background}
{disabled ? 'cursor-default text-text-muted' : 'cursor-pointer text-text-primary'}"
>
{#if lead}
<span class="flex w-8 shrink-0 items-center justify-center">{@render lead()}</span>
{/if}
<span class="min-w-0 flex-1">
<span class="block truncate {struck ? 'line-through' : ''}">{title}</span>
{#if subtitle || note}
<span class="block truncate text-xs text-text-secondary">
{subtitle}{#if note}{@render note()}{/if}
</span>
{/if}
</span>
{#if track}
<LikeButton entityType="track" entityId={track.id} />
<button
type="button"
aria-label="Play radio from this track"
title="Play radio"
onclick={onRadio}
class="hidden shrink-0 rounded p-1 text-text-secondary hover:text-text-primary sm:inline-flex"
>
<Radio size={16} strokeWidth={1} />
</button>
<button
type="button"
aria-label={`Add ${track.title} to queue`}
title="Add to queue"
onclick={onAdd}
class="hidden shrink-0 rounded p-1 text-text-secondary hover:text-text-primary sm:inline-flex"
>
<Plus size={16} strokeWidth={1} />
</button>
<TrackMenu {track} />
{/if}
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-text-secondary">
{formatDuration(durationSec)}
</span>
{#if trailing}
{@render trailing()}
{/if}
</div>
+104
View File
@@ -0,0 +1,104 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
import { makeTrack } from '#test-utils/fixtures/track.js';
const player = vi.hoisted(() => ({ current: undefined as { id: string } | undefined }));
vi.mock('#lib/player/store.svelte.js', () => ({
playNext: vi.fn(),
enqueueTrack: vi.fn(),
playQueue: vi.fn(),
playRadio: vi.fn(),
player
}));
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
import TrackListRow from './TrackListRow.svelte';
import { enqueueTrack, playRadio } from '#lib/player/store.svelte.js';
const track = makeTrack({ id: 't1', title: 'Roygbiv', duration_sec: 137 });
function base(overrides: Record<string, unknown> = {}) {
return {
title: 'Roygbiv',
subtitle: 'Boards of Canada · MHTRTC',
durationSec: 137,
track,
ariaLabel: 'Roygbiv',
onActivate: vi.fn(),
...overrides
};
}
afterEach(() => {
vi.clearAllMocks();
player.current = undefined;
});
describe('TrackListRow', () => {
test('draws title, subtitle and duration', () => {
render(TrackListRow, { props: base() });
expect(screen.getByText('Roygbiv')).toBeInTheDocument();
expect(screen.getByText('Boards of Canada · MHTRTC')).toBeInTheDocument();
expect(screen.getByText('2:17')).toBeInTheDocument();
});
test('a plain row is striped; the now-playing row is banded instead', () => {
const { unmount } = render(TrackListRow, { props: base() });
expect(screen.getByRole('button', { name: 'Roygbiv' }).className).toContain('odd:bg-surface/50');
unmount();
player.current = { id: 't1' };
render(TrackListRow, { props: base() });
const row = screen.getByRole('button', { name: 'Roygbiv' });
expect(row.className).toContain('border-l-accent');
expect(row.className).not.toContain('odd:bg-surface/50');
});
test('click and Enter activate the row', async () => {
const props = base();
render(TrackListRow, { props });
const row = screen.getByRole('button', { name: 'Roygbiv' });
await fireEvent.click(row);
await fireEvent.keyDown(row, { key: 'Enter' });
expect(props.onActivate).toHaveBeenCalledTimes(2);
});
// Enter on a control inside the row is that control's, not the row's.
// The old TrackRow played the track when Enter was pressed on "add".
test('Enter on an inner control does not activate the row', async () => {
const props = base();
render(TrackListRow, { props });
await fireEvent.keyDown(screen.getByRole('button', { name: /add roygbiv to queue/i }), { key: 'Enter' });
expect(props.onActivate).not.toHaveBeenCalled();
});
test('radio and add act on the track without activating the row', async () => {
const props = base();
render(TrackListRow, { props });
await fireEvent.click(screen.getByRole('button', { name: /play radio/i }));
await fireEvent.click(screen.getByRole('button', { name: /add roygbiv to queue/i }));
expect(playRadio).toHaveBeenCalledWith('t1');
expect(enqueueTrack).toHaveBeenCalledWith(track);
expect(props.onActivate).not.toHaveBeenCalled();
});
test('a row with no playable track hides like, radio, add and the menu', () => {
render(TrackListRow, { props: base({ track: null, disabled: true }) });
expect(screen.queryByRole('button', { name: /play radio/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /to queue/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /track actions for/i })).not.toBeInTheDocument();
});
test('a disabled row does not activate', async () => {
const props = base({ disabled: true });
render(TrackListRow, { props });
await fireEvent.click(screen.getByRole('button', { name: 'Roygbiv' }));
expect(props.onActivate).not.toHaveBeenCalled();
});
});
+38 -72
View File
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
// A track in a library list (album, Liked, Search). The look is
// TrackListRow's; what this adds is the track number and multi-select.
import type { TrackRef } from '#lib/api/types.js'; import type { TrackRef } from '#lib/api/types.js';
import { formatDuration } from '#lib/media/duration.js'; import { playQueue } from '#lib/player/store.svelte.js';
import { player, playQueue, enqueueTrack, playRadio } from '#lib/player/store.svelte.js'; import { Check } from 'lucide-svelte';
import { Plus, Check } from 'lucide-svelte'; import TrackListRow from './TrackListRow.svelte';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
import { import {
selection, toggleOne, selectRange selection, toggleOne, selectRange
} from '#lib/selection/store.svelte.js'; } from '#lib/selection/store.svelte.js';
@@ -14,25 +14,30 @@
let { let {
tracks, tracks,
index, index,
onPlay onPlay,
}: { tracks: TrackRef[]; index: number; onPlay?: PlayHandler } = $props(); showAlbum = true
}: {
tracks: TrackRef[];
index: number;
onPlay?: PlayHandler;
/** Off on the album page, where the album is the page itself. */
showAlbum?: boolean;
} = $props();
const track = $derived(tracks[index]); const track = $derived(tracks[index]);
const isCurrent = $derived(player.current?.id === track.id);
const isSelected = $derived(selection.isSelected(track.id)); const isSelected = $derived(selection.isSelected(track.id));
const selectMode = $derived(selection.active); const selectMode = $derived(selection.active);
function activate() { const subtitle = $derived(
if (onPlay) onPlay(tracks, index); showAlbum ? `${track.artist_name} · ${track.album_title}` : track.artist_name
else playQueue(tracks, index); );
}
function onRowClick(e: MouseEvent) { function activate(e: MouseEvent | KeyboardEvent) {
// In selection mode any click toggles; outside selection mode click // In selection mode any activation toggles; outside it, it plays.
// plays as before. Shift+click extends the range from the anchor // Shift+click extends the range from the anchor regardless of mode
// regardless of mode (enters selection mode if nothing was picked). // (entering selection mode if nothing was picked).
if (e.shiftKey) { if (e instanceof MouseEvent && e.shiftKey) {
e.preventDefault(); e.preventDefault();
selectRange(index, tracks); selectRange(index, tracks);
return; return;
@@ -41,15 +46,8 @@
toggleOne(track, index); toggleOne(track, index);
return; return;
} }
activate(); if (onPlay) onPlay(tracks, index);
} else playQueue(tracks, index);
function onRowKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (selectMode) toggleOne(track, index);
else activate();
}
} }
function onCheckboxClick(e: MouseEvent) { function onCheckboxClick(e: MouseEvent) {
@@ -57,43 +55,31 @@
if (e.shiftKey) selectRange(index, tracks); if (e.shiftKey) selectRange(index, tracks);
else toggleOne(track, index); else toggleOne(track, index);
} }
function onAddClick(e: MouseEvent) {
e.stopPropagation();
enqueueTrack(track);
}
function onRadioClick(e: MouseEvent) {
e.stopPropagation();
playRadio(track.id);
}
</script> </script>
<div <TrackListRow
role="button" title={track.title}
tabindex="0" {subtitle}
aria-label={track.title} durationSec={track.duration_sec}
data-selected={isSelected} {track}
onclick={onRowClick} ariaLabel={track.title}
onkeydown={onRowKey} {isSelected}
class="group grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent {isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''} {isSelected ? 'bg-surface-hover' : ''}" onActivate={activate}
> >
<span class="relative flex h-6 w-8 items-center justify-end"> {#snippet lead()}
<!-- Track number: visible by default; hidden when the row is hovered <!-- Track number by default; the checkbox replaces it on hover, in
(so the checkbox replaces it) or when this row is selected. --> selection mode, or when this row is selected. -->
<span <span
class="tabular-nums text-text-secondary class="tabular-nums text-text-secondary
{selectMode || isSelected ? 'hidden' : 'group-hover:hidden'}" {selectMode || isSelected ? 'hidden' : 'group-hover:hidden'}"
>{track.track_number ?? '—'}</span> >{track.track_number ?? '—'}</span>
<!-- Checkbox: hidden by default; revealed on row hover OR whenever
the selection bar is active OR this row is the selected one. -->
<button <button
type="button" type="button"
role="checkbox" role="checkbox"
aria-checked={isSelected} aria-checked={isSelected}
aria-label={isSelected ? `Deselect ${track.title}` : `Select ${track.title}`} aria-label={isSelected ? `Deselect ${track.title}` : `Select ${track.title}`}
onclick={onCheckboxClick} onclick={onCheckboxClick}
class="flex h-5 w-5 items-center justify-center rounded border class="h-5 w-5 items-center justify-center rounded border
{isSelected {isSelected
? 'border-accent bg-accent text-text-primary' ? 'border-accent bg-accent text-text-primary'
: 'border-border bg-surface text-transparent'} : 'border-border bg-surface text-transparent'}
@@ -101,25 +87,5 @@
> >
<Check size={14} strokeWidth={2} /> <Check size={14} strokeWidth={2} />
</button> </button>
</span> {/snippet}
<span class="truncate">{track.title}</span> </TrackListRow>
<LikeButton entityType="track" entityId={track.id} />
<button
type="button"
aria-label="Play radio from this track"
title="Play radio"
onclick={onRadioClick}
class="rounded p-1 text-text-secondary hover:text-text-primary"
>📻</button>
<button
type="button"
aria-label={`Add ${track.title} to queue`}
title="Add to queue"
onclick={onAddClick}
class="rounded p-1 text-text-secondary hover:text-text-primary"
>
<Plus size={16} strokeWidth={1} />
</button>
<TrackMenu {track} />
<span class="tabular-nums text-text-secondary">{formatDuration(track.duration_sec)}</span>
</div>
+4 -3
View File
@@ -1,7 +1,8 @@
// Translate a vertical drag offset (px) into an integer row-delta, // Translate a vertical drag offset (px) into an integer row-delta,
// given the measured row height (px). Used by QueueTrackRow when a // given the measured row height (px). Used by reorderDrag both to snap
// neodrag drop completes — we round to the nearest row boundary so // the row while it is dragged and to report where it was dropped — it
// the user's drop intent maps to a clean queue index. // 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 { export function offsetToDelta(offsetY: number, rowHeight: number): number {
if (rowHeight <= 0) return 0; if (rowHeight <= 0) return 0;
// `|| 0` normalizes -0 to +0. Math.round(-0.5) === -0; downstream // `|| 0` normalizes -0 to +0. Math.round(-0.5) === -0; downstream
@@ -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();
});
});
+98
View File
@@ -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<typeof draggable> | null = null;
function handleEl(): HTMLElement | undefined {
if (!opts?.handle) return undefined;
return node.querySelector<HTMLElement>(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;
}
};
}
+14
View File
@@ -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<T>(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;
}
+24 -58
View File
@@ -1,16 +1,18 @@
<script lang="ts"> <script lang="ts">
import { Play, Shuffle } from 'lucide-svelte'; import PageColumn from '#lib/components/PageColumn.svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { createAlbumQuery } from '#lib/api/queries.js'; import { createAlbumQuery } from '#lib/api/queries.js';
import { qk } from '#lib/api/queries.js'; import { qk } from '#lib/api/queries.js';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import TrackRow from '#lib/components/TrackRow.svelte'; import TrackRow from '#lib/components/TrackRow.svelte';
import TrackList from '#lib/components/TrackList.svelte';
import CollectionHeader from '#lib/components/CollectionHeader.svelte';
import { shuffled } from '#lib/utils/shuffle.js';
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte'; import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import LikeButton from '#lib/components/LikeButton.svelte'; import LikeButton from '#lib/components/LikeButton.svelte';
import { useDelayed } from '#lib/utils/useDelayed.svelte.js'; import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
import { formatDuration } from '#lib/media/duration.js'; import { formatDuration } from '#lib/media/duration.js';
import { FALLBACK_COVER } from '#lib/media/covers.js';
import type { ApiError } from '#lib/api/client.js'; import type { ApiError } from '#lib/api/client.js';
import { playQueue } from '#lib/player/store.svelte.js'; import { playQueue } from '#lib/player/store.svelte.js';
import { user } from '#lib/auth/store.svelte.js'; import { user } from '#lib/auth/store.svelte.js';
@@ -27,21 +29,8 @@
query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found' query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found'
); );
function onCoverError(e: Event) {
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}
let isStartingPlay = $state(false); let isStartingPlay = $state(false);
function shuffleArr<T>(items: 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;
}
function onPlay() { function onPlay() {
const data = query.data; const data = query.data;
if (isStartingPlay || !data || data.tracks.length === 0) return; if (isStartingPlay || !data || data.tracks.length === 0) return;
@@ -58,7 +47,7 @@
if (isStartingPlay || !data || data.tracks.length === 0) return; if (isStartingPlay || !data || data.tracks.length === 0) return;
isStartingPlay = true; isStartingPlay = true;
try { try {
playQueue(shuffleArr(data.tracks), 0); playQueue(shuffled(data.tracks), 0);
} finally { } finally {
isStartingPlay = false; isStartingPlay = false;
} }
@@ -91,7 +80,7 @@
<title>{pageTitle(query.data?.title ? `Album · ${query.data.title}` : 'Album')}</title> <title>{pageTitle(query.data?.title ? `Album · ${query.data.title}` : 'Album')}</title>
</svelte:head> </svelte:head>
<div class="space-y-6"> <PageColumn>
{#if notFound} {#if notFound}
<div role="alert" class="rounded border border-border bg-surface p-4"> <div role="alert" class="rounded border border-border bg-surface p-4">
<p>Album not found.</p> <p>Album not found.</p>
@@ -110,19 +99,17 @@
← {album.artist_name} ← {album.artist_name}
</a> </a>
<header class="flex flex-col gap-4 md:flex-row md:items-end"> <CollectionHeader
<img title={album.title}
src={album.cover_url} coverUrl={album.cover_url}
alt={album.title} coverAlt={album.title}
class="h-60 w-60 rounded object-cover" playDisabled={isStartingPlay || album.tracks.length === 0}
loading="lazy" {onPlay}
onerror={onCoverError} {onShuffle}
/> >
<div class="flex-1 space-y-2"> {#snippet titleExtra()}
<div class="flex items-center gap-3">
<h1 class="font-display text-2xl font-medium text-text-primary">{album.title}</h1>
<LikeButton entityType="album" entityId={album.id} size="lg" /> <LikeButton entityType="album" entityId={album.id} size="lg" />
</div> {/snippet}
<p> <p>
<a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a> <a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a>
</p> </p>
@@ -156,27 +143,7 @@
{album.track_count} {album.track_count === 1 ? 'track' : 'tracks'} {album.track_count} {album.track_count === 1 ? 'track' : 'tracks'}
· {formatDuration(album.duration_sec)} · {formatDuration(album.duration_sec)}
</p> </p>
<div class="flex items-center gap-2 pt-2"> {#snippet actions()}
<button
type="button"
aria-label={`Play ${album.title}`}
onclick={onPlay}
disabled={isStartingPlay || album.tracks.length === 0}
class="flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-medium text-text-primary hover:opacity-90 focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
>
<Play size={16} strokeWidth={1.5} fill="currentColor" />
Play
</button>
<button
type="button"
aria-label={`Shuffle ${album.title}`}
onclick={onShuffle}
disabled={isStartingPlay || album.tracks.length === 0}
class="flex items-center gap-2 rounded-md border border-border bg-surface px-4 py-2 text-sm font-medium text-text-primary hover:border-accent focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
>
<Shuffle size={16} strokeWidth={1.5} />
Shuffle
</button>
{#if isAdmin} {#if isAdmin}
<button <button
type="button" type="button"
@@ -190,20 +157,19 @@
</button> </button>
{/if} {/if}
{#if refetchError} {#if refetchError}
<span class="text-sm text-oxblood">{refetchError}</span> <span class="text-sm text-error-fg">{refetchError}</span>
{/if} {/if}
</div> {/snippet}
</div> </CollectionHeader>
</header>
{#if album.tracks.length === 0} {#if album.tracks.length === 0}
<p class="text-text-secondary">This album has no tracks.</p> <p class="text-text-secondary">This album has no tracks.</p>
{:else} {:else}
<div class="overflow-hidden rounded border border-border"> <TrackList>
{#each album.tracks as track, i (track.id)} {#each album.tracks as track, i (track.id)}
<TrackRow tracks={album.tracks} index={i} /> <TrackRow tracks={album.tracks} index={i} showAlbum={false} />
{/each} {/each}
</div> </TrackList>
{/if} {/if}
{/if} {/if}
</div> </PageColumn>
+18 -24
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Play } from 'lucide-svelte'; import PlayActions from '#lib/components/PlayActions.svelte';
import { shuffled } from '#lib/utils/shuffle.js';
import { page } from '$app/state'; import { page } from '$app/state';
import { import {
createArtistQuery, createArtistQuery,
@@ -36,31 +37,25 @@
query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found' query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found'
); );
// Mirrors ArtistCard's play overlay: fetch every track for the artist // Fetch every track for the artist (the server already honours per-user
// (server already honours per-user lidarr_quarantine), shuffle client-side, // lidarr_quarantine) and start the queue, in the server's order for Play
// start the queue. Bypassed when the artist has nothing to play. // and shuffled for Shuffle. Bypassed when the artist has nothing to play.
function shuffle<T>(items: 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;
}
let isStartingPlay = $state(false); let isStartingPlay = $state(false);
async function onPlay() { async function start(order: (tracks: TrackRef[]) => TrackRef[]) {
if (isStartingPlay || !id) return; if (isStartingPlay || !id) return;
isStartingPlay = true; isStartingPlay = true;
try { try {
const tracks = await api.get<TrackRef[]>(`/api/artists/${id}/tracks`); const tracks = await api.get<TrackRef[]>(`/api/artists/${id}/tracks`);
if (tracks.length === 0) return; if (tracks.length === 0) return;
playQueue(shuffle(tracks), 0); playQueue(order(tracks), 0);
} finally { } finally {
isStartingPlay = false; isStartingPlay = false;
} }
} }
const onPlay = () => start((tracks) => tracks);
const onShuffle = () => start(shuffled);
</script> </script>
<svelte:head> <svelte:head>
@@ -108,16 +103,15 @@
{/each} {/each}
</ul> </ul>
{/if} {/if}
</div> <div class="mt-3">
<button <PlayActions
type="button" name={detail.name}
aria-label={`Play ${detail.name}`}
onclick={onPlay}
disabled={isStartingPlay || detail.album_count === 0} disabled={isStartingPlay || detail.album_count === 0}
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow-sm transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50 disabled:hover:scale-100" {onPlay}
> {onShuffle}
<Play size={24} strokeWidth={1.5} fill="currentColor" class="ml-0.5" /> />
</button> </div>
</div>
</header> </header>
{#if detail.albums.length === 0} {#if detail.albums.length === 0}
+45 -1
View File
@@ -1,8 +1,10 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; 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 { flushSync } from 'svelte';
import { mockQuery } from '../../../test-utils/query'; import { mockQuery } from '../../../test-utils/query';
import { emptyLikesMock } from '../../../test-utils/mocks/likes'; 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'; import type { ArtistDetail, AlbumRef } from '#lib/api/types.js';
const state = vi.hoisted(() => ({ pageParams: { id: 'abc' } as Record<string, string> })); const state = vi.hoisted(() => ({ pageParams: { id: 'abc' } as Record<string, string> }));
@@ -23,7 +25,20 @@ vi.mock('#lib/api/queries.js', () => ({
vi.mock('#lib/api/likes.js', () => emptyLikesMock()); 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 ArtistPage from './+page.svelte';
import { api } from '#lib/api/client.js';
import { playQueue } from '#lib/player/store.svelte.js';
import { import {
createArtistQuery, createArtistQuery,
createSimilarArtistsQuery, createSimilarArtistsQuery,
@@ -132,4 +147,33 @@ describe('artist detail page', () => {
vi.useRealTimers(); 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<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
(api.get as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue(tracks);
render(ArtistPage);
await fireEvent.click(screen.getByRole('button', { name: 'Shuffle Alice' }));
await waitFor(() => expect(playQueue).toHaveBeenCalled());
const [queued] = (playQueue as ReturnType<typeof vi.fn>).mock.calls[0];
expect(queued.map((t: { id: string }) => t.id).sort()).toEqual(tracks.map((t) => t.id).sort());
});
});
}); });
+6 -4
View File
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import PageColumn from '#lib/components/PageColumn.svelte';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { createHistoryQuery } from '#lib/api/history.js'; import { createHistoryQuery } from '#lib/api/history.js';
import { groupByDay } from '#lib/utils/dayGroup.js'; import { groupByDay } from '#lib/utils/dayGroup.js';
import HistoryRow from '#lib/components/HistoryRow.svelte'; import HistoryRow from '#lib/components/HistoryRow.svelte';
import TrackList from '#lib/components/TrackList.svelte';
import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte'; import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte';
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte'; import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
@@ -28,7 +30,7 @@
<svelte:head><title>{pageTitle('Library · History')}</title></svelte:head> <svelte:head><title>{pageTitle('Library · History')}</title></svelte:head>
<div class="space-y-4"> <PageColumn>
<header class="flex flex-wrap items-end justify-between gap-3"> <header class="flex flex-wrap items-end justify-between gap-3">
<h1 class="font-display text-2xl font-medium text-text-primary">History</h1> <h1 class="font-display text-2xl font-medium text-text-primary">History</h1>
{#if flatEvents.length > 0} {#if flatEvents.length > 0}
@@ -62,11 +64,11 @@
<h2 class="sticky top-0 z-10 mb-2 bg-background py-1 text-lg font-medium text-text-secondary"> <h2 class="sticky top-0 z-10 mb-2 bg-background py-1 text-lg font-medium text-text-secondary">
{group.label} {group.label}
</h2> </h2>
<div class="divide-y divide-border"> <TrackList>
{#each group.events as event (event.id)} {#each group.events as event (event.id)}
<HistoryRow {event} /> <HistoryRow {event} />
{/each} {/each}
</div> </TrackList>
</section> </section>
{/each} {/each}
@@ -82,4 +84,4 @@
<p class="py-2 text-center text-sm text-text-secondary">End of history</p> <p class="py-2 text-center text-sm text-text-secondary">End of history</p>
{/if} {/if}
{/if} {/if}
</div> </PageColumn>
+16 -2
View File
@@ -1,8 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/svelte'; import { render, screen } from '@testing-library/svelte';
import HistoryPage from './+page.svelte';
import type { HistoryEvent, HistoryResponse } from '#lib/api/history.js'; import type { HistoryEvent, HistoryResponse } from '#lib/api/history.js';
import { makeTrack } from '#test-utils/fixtures/track.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}` }); const mkTrack = (id: string) => makeTrack({ id: `track-${id}`, title: `Song ${id}` });
@@ -44,14 +45,27 @@ vi.mock('#lib/api/history.js', async () => {
}; };
}); });
// Rows are TrackListRows, so LikeButton and TrackMenu render too.
vi.mock('#lib/player/store.svelte.js', () => ({ 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', () => ({ vi.mock('#lib/branding.js', () => ({
pageTitle: (s: string) => `Minstrel · ${s}` pageTitle: (s: string) => `Minstrel · ${s}`
})); }));
// Imported after the mocks: the component pulls in LikeButton, whose
// mocked module must already be initialised.
import HistoryPage from './+page.svelte';
describe('library/history page', () => { describe('library/history page', () => {
beforeEach(() => { beforeEach(() => {
mockData = { pages: [] }; mockData = { pages: [] };
+6 -4
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import PageColumn from '#lib/components/PageColumn.svelte';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { import {
createLikedTracksInfiniteQuery, createLikedTracksInfiniteQuery,
@@ -8,6 +9,7 @@
import ArtistCard from '#lib/components/ArtistCard.svelte'; import ArtistCard from '#lib/components/ArtistCard.svelte';
import AlbumCard from '#lib/components/AlbumCard.svelte'; import AlbumCard from '#lib/components/AlbumCard.svelte';
import TrackRow from '#lib/components/TrackRow.svelte'; import TrackRow from '#lib/components/TrackRow.svelte';
import TrackList from '#lib/components/TrackList.svelte';
import QuickFilter from '#lib/components/QuickFilter.svelte'; import QuickFilter from '#lib/components/QuickFilter.svelte';
import EmptyState from '#lib/components/EmptyState.svelte'; import EmptyState from '#lib/components/EmptyState.svelte';
@@ -49,7 +51,7 @@
<svelte:head><title>{pageTitle('Library · Liked')}</title></svelte:head> <svelte:head><title>{pageTitle('Library · Liked')}</title></svelte:head>
<div class="space-y-6"> <PageColumn>
<header class="flex flex-wrap items-end justify-between gap-3"> <header class="flex flex-wrap items-end justify-between gap-3">
<h1 class="text-2xl font-semibold">Liked</h1> <h1 class="text-2xl font-semibold">Liked</h1>
{#if artists.length + albums.length + tracks.length > 0} {#if artists.length + albums.length + tracks.length > 0}
@@ -157,11 +159,11 @@
{/snippet} {/snippet}
</EmptyState> </EmptyState>
{:else} {:else}
<div class="overflow-hidden rounded border border-border"> <TrackList>
{#each fTracks as t, i (t.id)} {#each fTracks as t, i (t.id)}
<TrackRow tracks={fTracks} index={i} /> <TrackRow tracks={fTracks} index={i} />
{/each} {/each}
</div> </TrackList>
{#if tracksQuery?.hasNextPage} {#if tracksQuery?.hasNextPage}
<button <button
type="button" type="button"
@@ -175,4 +177,4 @@
{/if} {/if}
</section> </section>
{/if} {/if}
</div> </PageColumn>
+1 -1
View File
@@ -21,7 +21,7 @@ vi.mock('#lib/api/likes.js', () => ({
vi.mock('#lib/api/client.js', () => apiClientMock()); vi.mock('#lib/api/client.js', () => apiClientMock());
vi.mock('#lib/player/store.svelte.js', () => ({ vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: vi.fn(), enqueueTrack: vi.fn(), enqueueTracks: vi.fn(), playQueue: vi.fn(), enqueueTrack: vi.fn(), enqueueTracks: vi.fn(), playRadio: vi.fn(),
player: { current: undefined } player: { current: undefined }
})); }));
+69 -51
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import PageColumn from '#lib/components/PageColumn.svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
@@ -6,6 +7,10 @@
import { Pencil, Trash2, Link as LinkIcon } from 'lucide-svelte'; import { Pencil, Trash2, Link as LinkIcon } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import PlaylistTrackRow from '#lib/components/PlaylistTrackRow.svelte'; import PlaylistTrackRow from '#lib/components/PlaylistTrackRow.svelte';
import TrackList from '#lib/components/TrackList.svelte';
import CollectionHeader from '#lib/components/CollectionHeader.svelte';
import { playlistTrackToRef } from '#lib/playlists/playlistTrackToRef.js';
import { shuffled } from '#lib/utils/shuffle.js';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import { import {
createPlaylistQuery, createPlaylistQuery,
@@ -23,7 +28,7 @@
import { systemPlaylistRefetch } from '#lib/playlists/systemRefetch.js'; import { systemPlaylistRefetch } from '#lib/playlists/systemRefetch.js';
import { systemRebuilt } from '#lib/serverEvents.svelte.js'; import { systemRebuilt } from '#lib/serverEvents.svelte.js';
import { pushToast } from '#lib/stores/toast.svelte.js'; import { pushToast } from '#lib/stores/toast.svelte.js';
import type { TrackRef } from '#lib/api/types.js'; import type { PlaylistDetail, TrackRef } from '#lib/api/types.js';
// SvelteKit types page.params.id as string | undefined (the route // SvelteKit types page.params.id as string | undefined (the route
// could in principle render with the slot empty); empty-string fallback // could in principle render with the slot empty); empty-string fallback
@@ -45,12 +50,23 @@
const cur = tracks.map((t) => t.position); const cur = tracks.map((t) => t.position);
const moved = cur.splice(fromPos, 1)[0]; const moved = cur.splice(fromPos, 1)[0];
cur.splice(clamped, 0, moved); cur.splice(clamped, 0, moved);
// Show the new order on drop rather than after the round trip, so the
// dragged row lands in its slot at once (#5393). Positions are
// renumbered the way the server renumbers them; the invalidate below
// replaces this with the server's copy either way.
const previous = playlistQuery.data;
const byPosition = new Map(tracks.map((t) => [t.position, t]));
queryClient.setQueryData<PlaylistDetail>(qk.playlist(id), {
...previous,
tracks: cur.map((pos, i) => ({ ...byPosition.get(pos)!, position: i }))
});
try { try {
await reorderPlaylist(id, cur); await reorderPlaylist(id, cur);
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
} catch (e: unknown) { } catch (e: unknown) {
queryClient.setQueryData(qk.playlist(id), previous);
alert(errMessage(e)); alert(errMessage(e));
} }
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
} }
async function onRemove(position: number) { async function onRemove(position: number) {
@@ -63,23 +79,30 @@
} }
} }
function onPlay(position: number) { // Every entry that can be played, in playlist order. playlistTrackToRef
if (!playlistQuery?.data) return; // drops both kinds of dead entry, removed tracks and missing files
const data = playlistQuery.data; // (#2527), so neither is ever queued.
// Skip rows where track_id is null (track was removed from library). function playable(data: PlaylistDetail): { refs: TrackRef[]; positions: number[] } {
const live = data.tracks.filter((t) => t.track_id !== null).map(toTrackRef); const refs: TrackRef[] = [];
// Find which `live` index corresponds to the clicked position. Some const positions: number[] = [];
// positions in the original list may be unavailable (filtered out), for (const row of data.tracks) {
// so the index in the playable list != the playlist position. const ref = playlistTrackToRef(row);
const clickedTrackID = data.tracks.find((t) => t.position === position)?.track_id; if (ref) {
const startIdx = live.findIndex((t) => t.id === clickedTrackID); refs.push(ref);
positions.push(row.position);
}
}
return { refs, positions };
}
function startQueue(data: PlaylistDetail, tracks: TrackRef[], startIdx: number) {
// #968: a system playlist played from its detail page is tagged with its // #968: a system playlist played from its detail page is tagged with its
// source (so play_started advances the rotation, matching the home tile and // source (so play_started advances the rotation, matching the home tile and
// the Android detail screen) and gets the self-heal closure so a stale // the Android detail screen) and gets the self-heal closure so a stale
// snapshot re-pulls on total failure. // snapshot re-pulls on total failure.
const variant = data.system_variant; const variant = data.system_variant;
if (variant != null) { if (variant != null) {
playQueue(live, Math.max(0, startIdx), { playQueue(tracks, Math.max(0, startIdx), {
source: variant, source: variant,
refetch: systemPlaylistRefetch({ refetch: systemPlaylistRefetch({
variant, variant,
@@ -88,30 +111,25 @@
}) })
}); });
} else { } else {
playQueue(live, Math.max(0, startIdx)); playQueue(tracks, Math.max(0, startIdx));
} }
} }
function toTrackRef(t: { function onPlay(position: number) {
track_id: string | null; if (!playlistQuery?.data) return;
album_id: string | null; const data = playlistQuery.data;
artist_id: string | null; const { refs, positions } = playable(data);
title: string; startQueue(data, refs, positions.indexOf(position));
album_title: string; }
artist_name: string;
duration_sec: number; function onPlayAll() {
stream_url: string | null; if (!playlistQuery?.data) return;
}): TrackRef { startQueue(playlistQuery.data, playable(playlistQuery.data).refs, 0);
return { }
id: t.track_id!,
title: t.title, function onShuffle() {
album_id: t.album_id ?? '', if (!playlistQuery?.data) return;
album_title: t.album_title, startQueue(playlistQuery.data, shuffled(playable(playlistQuery.data).refs), 0);
artist_id: t.artist_id ?? '',
artist_name: t.artist_name,
duration_sec: t.duration_sec,
stream_url: t.stream_url ?? ''
} as unknown as TrackRef;
} }
let editing = $state(false); let editing = $state(false);
@@ -217,7 +235,7 @@
<title>{pageTitle(playlistQuery?.data?.name ? `Playlist · ${playlistQuery.data.name}` : 'Playlist')}</title> <title>{pageTitle(playlistQuery?.data?.name ? `Playlist · ${playlistQuery.data.name}` : 'Playlist')}</title>
</svelte:head> </svelte:head>
<div class="mx-auto max-w-4xl px-4 py-6"> <PageColumn>
{#if playlistQuery?.isPending} {#if playlistQuery?.isPending}
<p class="text-text-muted">Loading…</p> <p class="text-text-muted">Loading…</p>
{:else if playlistQuery?.isError} {:else if playlistQuery?.isError}
@@ -243,23 +261,22 @@
</div> </div>
{/if} {/if}
{#if !editing} {#if !editing}
<header class="mb-6 flex items-start gap-4"> <CollectionHeader
<div class="h-32 w-32 shrink-0 overflow-hidden rounded-md bg-surface-hover"> title={pl.name}
{#if pl.cover_url} coverUrl={pl.cover_url}
<img src={pl.cover_url} alt="" class="h-full w-full object-cover" /> playDisabled={!pl.tracks.some((t) => playlistTrackToRef(t) !== null)}
{/if} onPlay={onPlayAll}
</div> {onShuffle}
<div class="min-w-0 flex-1"> >
<h1 class="truncate text-2xl font-medium text-text-primary">{pl.name}</h1>
{#if pl.description} {#if pl.description}
<p class="mt-1 text-sm text-text-muted">{pl.description}</p> <p class="text-sm text-text-muted">{pl.description}</p>
{/if} {/if}
<p class="mt-2 text-xs text-text-muted"> <p class="text-xs text-text-muted">
{pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'} {pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'}
{#if pl.is_public}· public{:else}· private{/if} {#if pl.is_public}· public{:else}· private{/if}
{#if !isOwner}· by {pl.owner_username}{/if} {#if !isOwner}· by {pl.owner_username}{/if}
</p> </p>
</div> {#snippet tools()}
{#if pl.refreshable && pl.system_variant} {#if pl.refreshable && pl.system_variant}
<button <button
type="button" type="button"
@@ -300,7 +317,8 @@
<Trash2 size={16} strokeWidth={1} /> <Trash2 size={16} strokeWidth={1} />
</button> </button>
{/if} {/if}
</header> {/snippet}
</CollectionHeader>
{:else} {:else}
<section class="mb-6 rounded-md border border-border bg-surface p-4"> <section class="mb-6 rounded-md border border-border bg-surface p-4">
<label class="block text-sm font-medium text-text-primary"> <label class="block text-sm font-medium text-text-primary">
@@ -342,9 +360,8 @@
</section> </section>
{/if} {/if}
<section class="rounded-md border border-border bg-surface">
{#if pl.tracks.length === 0} {#if pl.tracks.length === 0}
<p class="px-3 py-6 text-sm text-text-muted"> <p class="rounded-md border border-border px-3 py-6 text-sm text-text-muted">
{#if isOwner} {#if isOwner}
No tracks yet. Add some via the "Add to playlist…" entry on any track row. No tracks yet. Add some via the "Add to playlist…" entry on any track row.
{:else} {:else}
@@ -352,6 +369,7 @@
{/if} {/if}
</p> </p>
{:else} {:else}
<TrackList>
{#each pl.tracks as row (row.position)} {#each pl.tracks as row (row.position)}
<PlaylistTrackRow <PlaylistTrackRow
{row} {row}
@@ -361,7 +379,7 @@
onMove={onMove} onMove={onMove}
/> />
{/each} {/each}
</TrackList>
{/if} {/if}
</section>
{/if} {/if}
</div> </PageColumn>
+41 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query'; import { mockQuery } from '../../../test-utils/query';
import { emptyQuarantineMock } from '../../../test-utils/mocks/quarantine'; import { emptyQuarantineMock } from '../../../test-utils/mocks/quarantine';
@@ -31,6 +31,7 @@ vi.mock('#lib/player/store.svelte.js', () => ({
enqueueTracks: vi.fn(), enqueueTracks: vi.fn(),
playNext: vi.fn(), playNext: vi.fn(),
enqueueTrack: vi.fn(), enqueueTrack: vi.fn(),
playRadio: vi.fn(),
player: { current: undefined } player: { current: undefined }
})); }));
@@ -44,6 +45,7 @@ vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
import PlaylistDetailPage from './+page.svelte'; import PlaylistDetailPage from './+page.svelte';
import { createPlaylistQuery, deletePlaylist, refreshSystem } from '#lib/api/playlists.js'; import { createPlaylistQuery, deletePlaylist, refreshSystem } from '#lib/api/playlists.js';
import { playQueue } from '#lib/player/store.svelte.js';
const mockedQuery = createPlaylistQuery as ReturnType<typeof vi.fn>; const mockedQuery = createPlaylistQuery as ReturnType<typeof vi.fn>;
@@ -57,7 +59,45 @@ const ownDetail: PlaylistDetail = {
] ]
}; };
afterEach(() => vi.clearAllMocks());
describe('Playlist detail page', () => { describe('Playlist detail page', () => {
// A missing file (#2527) stays in the list but is never queued: the
// page used to build its queue with its own converter, which only
// skipped removed tracks.
test('Play queues every playable entry and skips a missing file', async () => {
const withMissing: PlaylistDetail = {
...ownDetail,
track_count: 3,
tracks: [
...ownDetail.tracks,
{ ...ownDetail.tracks[1], position: 2, track_id: 't3', title: 'C', stream_url: null, unavailable: true }
]
};
mockedQuery.mockReturnValue(mockQuery({ data: withMissing }));
render(PlaylistDetailPage);
await fireEvent.click(screen.getByRole('button', { name: 'Play Mine' }));
const [queued, start] = (playQueue as ReturnType<typeof vi.fn>).mock.calls[0];
expect(queued.map((t: { id: string }) => t.id)).toEqual(['t1', 't2']);
expect(start).toBe(0);
});
test('clicking a row starts the queue at that track', async () => {
mockedQuery.mockReturnValue(mockQuery({ data: ownDetail }));
render(PlaylistDetailPage);
await fireEvent.click(screen.getByText('B'));
const [queued, start] = (playQueue as ReturnType<typeof vi.fn>).mock.calls.at(-1)!;
expect(queued[start].id).toBe('t2');
});
test('Shuffle queues the same playable entries', async () => {
mockedQuery.mockReturnValue(mockQuery({ data: ownDetail }));
render(PlaylistDetailPage);
await fireEvent.click(screen.getByRole('button', { name: 'Shuffle Mine' }));
const [queued] = (playQueue as ReturnType<typeof vi.fn>).mock.calls.at(-1)!;
expect(queued.map((t: { id: string }) => t.id).sort()).toEqual(['t1', 't2']);
});
test('renders header + tracks for owner', () => { test('renders header + tracks for owner', () => {
mockedQuery.mockReturnValue(mockQuery({ data: ownDetail })); mockedQuery.mockReturnValue(mockQuery({ data: ownDetail }));
render(PlaylistDetailPage); render(PlaylistDetailPage);
+3 -2
View File
@@ -5,6 +5,7 @@
import ArtistCard from '#lib/components/ArtistCard.svelte'; import ArtistCard from '#lib/components/ArtistCard.svelte';
import AlbumCard from '#lib/components/AlbumCard.svelte'; import AlbumCard from '#lib/components/AlbumCard.svelte';
import TrackRow from '#lib/components/TrackRow.svelte'; import TrackRow from '#lib/components/TrackRow.svelte';
import TrackList from '#lib/components/TrackList.svelte';
import SearchSkeleton from '#lib/components/SearchSkeleton.svelte'; import SearchSkeleton from '#lib/components/SearchSkeleton.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '#lib/utils/useDelayed.svelte.js'; import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
@@ -104,11 +105,11 @@
</a> </a>
{/if} {/if}
</header> </header>
<div class="overflow-hidden rounded border border-border"> <TrackList>
{#each tracks.items as t, i (t.id)} {#each tracks.items as t, i (t.id)}
<TrackRow tracks={tracks.items} index={i} onPlay={onTrackPlay} /> <TrackRow tracks={tracks.items} index={i} onPlay={onTrackPlay} />
{/each} {/each}
</div> </TrackList>
</section> </section>
{/if} {/if}
{/if} {/if}
+7 -5
View File
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import PageColumn from '#lib/components/PageColumn.svelte';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { page } from '$app/state'; import { page } from '$app/state';
import { createSearchTracksInfiniteQuery } from '#lib/api/queries.js'; import { createSearchTracksInfiniteQuery } from '#lib/api/queries.js';
import TrackRow from '#lib/components/TrackRow.svelte'; import TrackRow from '#lib/components/TrackRow.svelte';
import TrackList from '#lib/components/TrackList.svelte';
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte'; import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '#lib/utils/useDelayed.svelte.js'; import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
@@ -24,12 +26,12 @@
<svelte:head><title>{pageTitle('Search · Tracks')}</title></svelte:head> <svelte:head><title>{pageTitle('Search · Tracks')}</title></svelte:head>
<div class="space-y-4"> <PageColumn>
<header> <header>
<a href={`/search?q=${encodeURIComponent(q)}`} class="text-sm text-text-secondary hover:text-text-primary"> <a href={`/search?q=${encodeURIComponent(q)}`} class="text-sm text-text-secondary hover:text-text-primary">
← Back to search ← Back to search
</a> </a>
<h1 class="mt-2 text-2xl font-semibold">Tracks matching '{q}'</h1> <h1 class="mt-2 font-display text-2xl font-medium text-text-primary">Tracks matching '{q}'</h1>
{#if !query?.isPending && !query?.isError && q} {#if !query?.isPending && !query?.isError && q}
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'track' : 'tracks'}</p> <p class="text-sm text-text-secondary">{total} {total === 1 ? 'track' : 'tracks'}</p>
{/if} {/if}
@@ -44,11 +46,11 @@
{:else if total === 0} {:else if total === 0}
<p class="text-text-secondary">No track matches.</p> <p class="text-text-secondary">No track matches.</p>
{:else} {:else}
<div class="overflow-hidden rounded border border-border"> <TrackList>
{#each tracks as t, i (t.id)} {#each tracks as t, i (t.id)}
<TrackRow tracks={tracks} index={i} onPlay={onTrackPlay} /> <TrackRow tracks={tracks} index={i} onPlay={onTrackPlay} />
{/each} {/each}
</div> </TrackList>
{#if query?.hasNextPage} {#if query?.hasNextPage}
<button <button
type="button" type="button"
@@ -60,4 +62,4 @@
</button> </button>
{/if} {/if}
{/if} {/if}
</div> </PageColumn>