Merge pull request 'One track-list shape across the web app, drag reorder that snaps (M494)' (#152) from dev into main
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m36s
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 27s
release / integration (push) Successful in 4m20s
release / android (push) Successful in 4m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m36s
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 27s
release / integration (push) Successful in 4m20s
release / android (push) Successful in 4m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
This commit was merged in pull request #152.
This commit is contained in:
@@ -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>
|
||||||
@@ -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">
|
{timestamp}
|
||||||
{event.track.artist_name} · {event.track.album_title}
|
</span>
|
||||||
</div>
|
{/snippet}
|
||||||
</div>
|
</TrackListRow>
|
||||||
<span title={tooltip} class="shrink-0 text-xs text-text-secondary">
|
|
||||||
{timestamp}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
: null
|
||||||
onMove?.(row.position, row.position + delta);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
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)}
|
||||||
>
|
>
|
||||||
{#if isOwner}
|
{#snippet lead()}
|
||||||
<button
|
{#if isOwner}
|
||||||
type="button"
|
<button
|
||||||
disabled={!canDrag}
|
type="button"
|
||||||
aria-label="Reorder track (drag or use arrow keys)"
|
data-reorder-handle
|
||||||
aria-keyshortcuts="ArrowUp ArrowDown"
|
disabled={!canDrag}
|
||||||
onkeydown={handleHandleKeydown}
|
aria-label="Reorder track (drag or use arrow keys)"
|
||||||
class="shrink-0 cursor-grab text-text-muted hover:text-text-primary
|
aria-keyshortcuts="ArrowUp ArrowDown"
|
||||||
active:cursor-grabbing focus-visible:outline-solid focus-visible:outline-2
|
onclick={(e) => e.stopPropagation()}
|
||||||
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50"
|
onkeydown={handleHandleKeydown}
|
||||||
>
|
class="cursor-grab p-1 text-text-muted hover:text-text-primary
|
||||||
<GripVertical size={14} strokeWidth={1} />
|
active:cursor-grabbing focus-visible:outline-solid focus-visible:outline-2
|
||||||
</button>
|
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
{/if}
|
>
|
||||||
|
<GripVertical size={16} strokeWidth={1} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<button
|
{#snippet note()}
|
||||||
type="button"
|
{#if isMissingFile}
|
||||||
class="min-w-0 flex-1 text-left"
|
<span class="text-error-fg"> · File missing</span>
|
||||||
onclick={() => !isUnavailable && onPlay(row.position)}
|
{/if}
|
||||||
disabled={isUnavailable}
|
{/snippet}
|
||||||
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}
|
|
||||||
<span class="text-action-destructive"> · File missing</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span class="shrink-0 text-xs text-text-muted">{format(row.duration_sec)}</span>
|
{#snippet trailing()}
|
||||||
|
{#if isOwner}
|
||||||
{#if !isUnavailable && liveTrack}
|
<button
|
||||||
<LikeButton entityType="track" entityId={liveTrack.id} />
|
type="button"
|
||||||
<TrackMenu track={liveTrack} />
|
onclick={(e) => { e.stopPropagation(); onRemove(row.position); }}
|
||||||
{/if}
|
aria-label={`Remove ${row.title} from playlist`}
|
||||||
|
class="shrink-0 rounded p-1 text-text-muted hover:bg-surface hover:text-action-destructive"
|
||||||
{#if isOwner}
|
>
|
||||||
<button
|
<X size={16} strokeWidth={1} />
|
||||||
type="button"
|
</button>
|
||||||
onclick={() => onRemove(row.position)}
|
{/if}
|
||||||
aria-label={`Remove ${row.title} from playlist`}
|
{/snippet}
|
||||||
class="shrink-0 rounded p-1 text-text-muted hover:bg-surface hover:text-action-destructive"
|
</TrackListRow>
|
||||||
>
|
|
||||||
<X size={14} strokeWidth={1} />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,100 +99,77 @@
|
|||||||
← {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">
|
<LikeButton entityType="album" entityId={album.id} size="lg" />
|
||||||
<h1 class="font-display text-2xl font-medium text-text-primary">{album.title}</h1>
|
{/snippet}
|
||||||
<LikeButton entityType="album" entityId={album.id} size="lg" />
|
<p>
|
||||||
</div>
|
<a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a>
|
||||||
<p>
|
</p>
|
||||||
<a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a>
|
<!-- Year and genre are quick-jumps into the browse axes (#367): from
|
||||||
|
an album you like, one click to everything else from that year or
|
||||||
|
in that genre. -->
|
||||||
|
{#if album.year}
|
||||||
|
<p class="text-sm">
|
||||||
|
<a href={`/library/years?y=${album.year}`} class="text-accent-fg hover:underline">
|
||||||
|
{album.year}
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<!-- Year and genre are quick-jumps into the browse axes (#367): from
|
{/if}
|
||||||
an album you like, one click to everything else from that year or
|
{#if album.genres?.length}
|
||||||
in that genre. -->
|
<ul class="flex flex-wrap gap-1.5">
|
||||||
{#if album.year}
|
{#each album.genres as genre (genre)}
|
||||||
<p class="text-sm">
|
<li>
|
||||||
<a href={`/library/years?y=${album.year}`} class="text-accent-fg hover:underline">
|
<a
|
||||||
{album.year}
|
href={`/library/genres?g=${encodeURIComponent(genre)}`}
|
||||||
</a>
|
class="inline-block rounded-full border border-border px-2.5 py-0.5 text-xs
|
||||||
</p>
|
text-text-secondary hover:bg-surface-hover hover:text-text-primary
|
||||||
{/if}
|
focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
{#if album.genres?.length}
|
>
|
||||||
<ul class="flex flex-wrap gap-1.5">
|
{genre}
|
||||||
{#each album.genres as genre (genre)}
|
</a>
|
||||||
<li>
|
</li>
|
||||||
<a
|
{/each}
|
||||||
href={`/library/genres?g=${encodeURIComponent(genre)}`}
|
</ul>
|
||||||
class="inline-block rounded-full border border-border px-2.5 py-0.5 text-xs
|
{/if}
|
||||||
text-text-secondary hover:bg-surface-hover hover:text-text-primary
|
<p class="text-sm text-text-secondary">
|
||||||
focus-visible:ring-2 focus-visible:ring-accent"
|
{album.track_count} {album.track_count === 1 ? 'track' : 'tracks'}
|
||||||
>
|
· {formatDuration(album.duration_sec)}
|
||||||
{genre}
|
</p>
|
||||||
</a>
|
{#snippet actions()}
|
||||||
</li>
|
{#if isAdmin}
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
<p class="text-sm text-text-secondary">
|
|
||||||
{album.track_count} {album.track_count === 1 ? 'track' : 'tracks'}
|
|
||||||
· {formatDuration(album.duration_sec)}
|
|
||||||
</p>
|
|
||||||
<div class="flex items-center gap-2 pt-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Play ${album.title}`}
|
onclick={onRefetchCover}
|
||||||
onclick={onPlay}
|
disabled={refetching}
|
||||||
disabled={isStartingPlay || album.tracks.length === 0}
|
aria-label={`Refetch cover for ${album.title}`}
|
||||||
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"
|
title={album.cover_art_source === 'none' ? 'No cover found previously — try again' : 'Refetch from MusicBrainz'}
|
||||||
|
class="flex items-center gap-2 rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-text-secondary hover:border-accent disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Play size={16} strokeWidth={1.5} fill="currentColor" />
|
Refetch cover
|
||||||
Play
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/if}
|
||||||
type="button"
|
{#if refetchError}
|
||||||
aria-label={`Shuffle ${album.title}`}
|
<span class="text-sm text-error-fg">{refetchError}</span>
|
||||||
onclick={onShuffle}
|
{/if}
|
||||||
disabled={isStartingPlay || album.tracks.length === 0}
|
{/snippet}
|
||||||
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"
|
</CollectionHeader>
|
||||||
>
|
|
||||||
<Shuffle size={16} strokeWidth={1.5} />
|
|
||||||
Shuffle
|
|
||||||
</button>
|
|
||||||
{#if isAdmin}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={onRefetchCover}
|
|
||||||
disabled={refetching}
|
|
||||||
aria-label={`Refetch cover for ${album.title}`}
|
|
||||||
title={album.cover_art_source === 'none' ? 'No cover found previously — try again' : 'Refetch from MusicBrainz'}
|
|
||||||
class="flex items-center gap-2 rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-text-secondary hover:border-accent disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Refetch cover
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if refetchError}
|
|
||||||
<span class="text-sm text-oxblood">{refetchError}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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 class="mt-3">
|
||||||
|
<PlayActions
|
||||||
|
name={detail.name}
|
||||||
|
disabled={isStartingPlay || detail.album_count === 0}
|
||||||
|
{onPlay}
|
||||||
|
{onShuffle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`Play ${detail.name}`}
|
|
||||||
onclick={onPlay}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Play size={24} strokeWidth={1.5} fill="currentColor" class="ml-0.5" />
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if detail.albums.length === 0}
|
{#if detail.albums.length === 0}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: [] };
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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,64 +261,64 @@
|
|||||||
</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)}
|
||||||
|
onPlay={onPlayAll}
|
||||||
|
{onShuffle}
|
||||||
|
>
|
||||||
|
{#if pl.description}
|
||||||
|
<p class="text-sm text-text-muted">{pl.description}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="text-xs text-text-muted">
|
||||||
|
{pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'}
|
||||||
|
{#if pl.is_public}· public{:else}· private{/if}
|
||||||
|
{#if !isOwner}· by {pl.owner_username}{/if}
|
||||||
|
</p>
|
||||||
|
{#snippet tools()}
|
||||||
|
{#if pl.refreshable && pl.system_variant}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={refreshingSystem}
|
||||||
|
onclick={() => onRefreshSystem(pl.system_variant as string)}
|
||||||
|
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
|
||||||
|
aria-label="Refresh playlist"
|
||||||
|
>
|
||||||
|
{refreshingSystem ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{#if isOwner && pl.is_public}
|
||||||
<div class="min-w-0 flex-1">
|
<button
|
||||||
<h1 class="truncate text-2xl font-medium text-text-primary">{pl.name}</h1>
|
type="button"
|
||||||
{#if pl.description}
|
onclick={onCopyLink}
|
||||||
<p class="mt-1 text-sm text-text-muted">{pl.description}</p>
|
aria-label="Copy public link to this playlist"
|
||||||
|
title="Copy link"
|
||||||
|
class="rounded p-2 text-text-muted hover:bg-surface-hover hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<LinkIcon size={16} strokeWidth={1} />
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="mt-2 text-xs text-text-muted">
|
{#if isOwner}
|
||||||
{pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'}
|
<button
|
||||||
{#if pl.is_public}· public{:else}· private{/if}
|
type="button"
|
||||||
{#if !isOwner}· by {pl.owner_username}{/if}
|
onclick={startEdit}
|
||||||
</p>
|
aria-label="Edit playlist"
|
||||||
</div>
|
class="rounded p-2 text-text-muted hover:bg-surface-hover hover:text-text-primary"
|
||||||
{#if pl.refreshable && pl.system_variant}
|
>
|
||||||
<button
|
<Pencil size={16} strokeWidth={1} />
|
||||||
type="button"
|
</button>
|
||||||
disabled={refreshingSystem}
|
<button
|
||||||
onclick={() => onRefreshSystem(pl.system_variant as string)}
|
type="button"
|
||||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
|
onclick={submitDelete}
|
||||||
aria-label="Refresh playlist"
|
aria-label="Delete playlist"
|
||||||
>
|
class="rounded p-2 text-text-muted hover:bg-surface-hover hover:text-action-destructive"
|
||||||
{refreshingSystem ? 'Refreshing…' : 'Refresh'}
|
>
|
||||||
</button>
|
<Trash2 size={16} strokeWidth={1} />
|
||||||
{/if}
|
</button>
|
||||||
{#if isOwner && pl.is_public}
|
{/if}
|
||||||
<button
|
{/snippet}
|
||||||
type="button"
|
</CollectionHeader>
|
||||||
onclick={onCopyLink}
|
|
||||||
aria-label="Copy public link to this playlist"
|
|
||||||
title="Copy link"
|
|
||||||
class="rounded p-2 text-text-muted hover:bg-surface-hover hover:text-text-primary"
|
|
||||||
>
|
|
||||||
<LinkIcon size={16} strokeWidth={1} />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if isOwner}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={startEdit}
|
|
||||||
aria-label="Edit playlist"
|
|
||||||
class="rounded p-2 text-text-muted hover:bg-surface-hover hover:text-text-primary"
|
|
||||||
>
|
|
||||||
<Pencil size={16} strokeWidth={1} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={submitDelete}
|
|
||||||
aria-label="Delete playlist"
|
|
||||||
class="rounded p-2 text-text-muted hover:bg-surface-hover hover:text-action-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} strokeWidth={1} />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
{: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,16 +360,16 @@
|
|||||||
</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="rounded-md border border-border px-3 py-6 text-sm text-text-muted">
|
||||||
<p class="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}
|
No tracks in this playlist.
|
||||||
No tracks in this playlist.
|
{/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}
|
||||||
{/if}
|
</TrackList>
|
||||||
</section>
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</PageColumn>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user