One track-list shape across the web app, drag reorder that snaps (M494) #152
@@ -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>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
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 TrackListRow from './TrackListRow.svelte';
|
||||||
|
import Cover from './Cover.svelte';
|
||||||
|
|
||||||
let { event } = $props<{ event: HistoryEvent }>();
|
let { event } = $props<{ event: HistoryEvent }>();
|
||||||
|
|
||||||
@@ -50,12 +51,9 @@
|
|||||||
onActivate={handleClick}
|
onActivate={handleClick}
|
||||||
>
|
>
|
||||||
{#snippet lead()}
|
{#snippet lead()}
|
||||||
<img
|
<span class="block h-8 w-8 overflow-hidden rounded">
|
||||||
src={coverUrl(event.track.album_id)}
|
<Cover src={coverUrl(event.track.album_id)} />
|
||||||
alt=""
|
</span>
|
||||||
class="h-8 w-8 rounded object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,17 +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 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';
|
||||||
@@ -28,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;
|
||||||
@@ -59,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;
|
||||||
}
|
}
|
||||||
@@ -92,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>
|
||||||
@@ -111,19 +99,17 @@
|
|||||||
← {album.artist_name}
|
← {album.artist_name}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<header class="flex flex-col gap-4 md:flex-row md:items-end">
|
<CollectionHeader
|
||||||
<img
|
title={album.title}
|
||||||
src={album.cover_url}
|
coverUrl={album.cover_url}
|
||||||
alt={album.title}
|
coverAlt={album.title}
|
||||||
class="h-60 w-60 rounded object-cover"
|
playDisabled={isStartingPlay || album.tracks.length === 0}
|
||||||
loading="lazy"
|
{onPlay}
|
||||||
onerror={onCoverError}
|
{onShuffle}
|
||||||
/>
|
>
|
||||||
<div class="flex-1 space-y-2">
|
{#snippet titleExtra()}
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<h1 class="font-display text-2xl font-medium text-text-primary">{album.title}</h1>
|
|
||||||
<LikeButton entityType="album" entityId={album.id} size="lg" />
|
<LikeButton entityType="album" entityId={album.id} size="lg" />
|
||||||
</div>
|
{/snippet}
|
||||||
<p>
|
<p>
|
||||||
<a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a>
|
<a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -157,27 +143,7 @@
|
|||||||
{album.track_count} {album.track_count === 1 ? 'track' : 'tracks'}
|
{album.track_count} {album.track_count === 1 ? 'track' : 'tracks'}
|
||||||
· {formatDuration(album.duration_sec)}
|
· {formatDuration(album.duration_sec)}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center gap-2 pt-2">
|
{#snippet actions()}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`Play ${album.title}`}
|
|
||||||
onclick={onPlay}
|
|
||||||
disabled={isStartingPlay || album.tracks.length === 0}
|
|
||||||
class="flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-medium text-text-primary hover:opacity-90 focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Play size={16} strokeWidth={1.5} fill="currentColor" />
|
|
||||||
Play
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`Shuffle ${album.title}`}
|
|
||||||
onclick={onShuffle}
|
|
||||||
disabled={isStartingPlay || album.tracks.length === 0}
|
|
||||||
class="flex items-center gap-2 rounded-md border border-border bg-surface px-4 py-2 text-sm font-medium text-text-primary hover:border-accent focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Shuffle size={16} strokeWidth={1.5} />
|
|
||||||
Shuffle
|
|
||||||
</button>
|
|
||||||
{#if isAdmin}
|
{#if isAdmin}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -191,11 +157,10 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if refetchError}
|
{#if refetchError}
|
||||||
<span class="text-sm text-oxblood">{refetchError}</span>
|
<span class="text-sm text-error-fg">{refetchError}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{/snippet}
|
||||||
</div>
|
</CollectionHeader>
|
||||||
</header>
|
|
||||||
|
|
||||||
{#if album.tracks.length === 0}
|
{#if album.tracks.length === 0}
|
||||||
<p class="text-text-secondary">This album has no tracks.</p>
|
<p class="text-text-secondary">This album has no tracks.</p>
|
||||||
@@ -207,4 +172,4 @@
|
|||||||
</TrackList>
|
</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>
|
<div class="mt-3">
|
||||||
<button
|
<PlayActions
|
||||||
type="button"
|
name={detail.name}
|
||||||
aria-label={`Play ${detail.name}`}
|
|
||||||
onclick={onPlay}
|
|
||||||
disabled={isStartingPlay || detail.album_count === 0}
|
disabled={isStartingPlay || detail.album_count === 0}
|
||||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow-sm transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50 disabled:hover:scale-100"
|
{onPlay}
|
||||||
>
|
{onShuffle}
|
||||||
<Play size={24} strokeWidth={1.5} fill="currentColor" class="ml-0.5" />
|
/>
|
||||||
</button>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if detail.albums.length === 0}
|
{#if detail.albums.length === 0}
|
||||||
|
|||||||
@@ -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,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 { 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';
|
||||||
@@ -29,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}
|
||||||
@@ -83,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,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,
|
||||||
@@ -50,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}
|
||||||
@@ -176,4 +177,4 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</PageColumn>
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -7,6 +8,9 @@
|
|||||||
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 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,
|
||||||
@@ -75,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,
|
||||||
@@ -100,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);
|
||||||
@@ -229,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}
|
||||||
@@ -255,23 +261,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !editing}
|
{#if !editing}
|
||||||
<header class="mb-6 flex items-start gap-4">
|
<CollectionHeader
|
||||||
<div class="h-32 w-32 shrink-0 overflow-hidden rounded-md bg-surface-hover">
|
title={pl.name}
|
||||||
{#if pl.cover_url}
|
coverUrl={pl.cover_url}
|
||||||
<img src={pl.cover_url} alt="" class="h-full w-full object-cover" />
|
playDisabled={!pl.tracks.some((t) => playlistTrackToRef(t) !== null)}
|
||||||
{/if}
|
onPlay={onPlayAll}
|
||||||
</div>
|
{onShuffle}
|
||||||
<div class="min-w-0 flex-1">
|
>
|
||||||
<h1 class="truncate text-2xl font-medium text-text-primary">{pl.name}</h1>
|
|
||||||
{#if pl.description}
|
{#if pl.description}
|
||||||
<p class="mt-1 text-sm text-text-muted">{pl.description}</p>
|
<p class="text-sm text-text-muted">{pl.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="mt-2 text-xs text-text-muted">
|
<p class="text-xs text-text-muted">
|
||||||
{pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'}
|
{pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'}
|
||||||
{#if pl.is_public}· public{:else}· private{/if}
|
{#if pl.is_public}· public{:else}· private{/if}
|
||||||
{#if !isOwner}· by {pl.owner_username}{/if}
|
{#if !isOwner}· by {pl.owner_username}{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
{#snippet tools()}
|
||||||
{#if pl.refreshable && pl.system_variant}
|
{#if pl.refreshable && pl.system_variant}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -312,7 +317,8 @@
|
|||||||
<Trash2 size={16} strokeWidth={1} />
|
<Trash2 size={16} strokeWidth={1} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
{/snippet}
|
||||||
|
</CollectionHeader>
|
||||||
{:else}
|
{:else}
|
||||||
<section class="mb-6 rounded-md border border-border bg-surface p-4">
|
<section class="mb-6 rounded-md border border-border bg-surface p-4">
|
||||||
<label class="block text-sm font-medium text-text-primary">
|
<label class="block text-sm font-medium text-text-primary">
|
||||||
@@ -376,4 +382,4 @@
|
|||||||
</TrackList>
|
</TrackList>
|
||||||
{/if}
|
{/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';
|
||||||
@@ -45,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>;
|
||||||
|
|
||||||
@@ -58,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);
|
||||||
|
|||||||
@@ -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 { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { createSearchTracksInfiniteQuery } from '#lib/api/queries.js';
|
import { createSearchTracksInfiniteQuery } from '#lib/api/queries.js';
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
|
|
||||||
<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
|
||||||
@@ -61,4 +62,4 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</PageColumn>
|
||||||
|
|||||||
Reference in New Issue
Block a user