feat(web): shared collection header, Play and Shuffle, and the centred page column (M494)
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m48s
release / integration (push) Successful in 5m51s
release / android (push) Successful in 6m53s
release / Build signed APK (releases and dev) (push) Successful in 7m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m29s
release / Verify release artifacts (tag releases only) (push) Skipped

- CollectionHeader holds cover, title, facts, then Play and Shuffle, for
  album and playlist. The playlist page gains Play and Shuffle; it had
  neither. The cover uses the shared Cover component, so the playlist
  header also gets the fallback image.
- PlayActions is the one Play and Shuffle pair: filled Play, outlined
  Shuffle, on album, playlist and artist pages (operator, 2026-10-08).
  The artist page's round icon button said Play but shuffled; Play now
  keeps the server's order, and Shuffle shuffles.
- PageColumn is the playlist page's centred column, now used by album,
  playlist, Liked, History and Search → Tracks.
- The playlist page builds its queue with playlistTrackToRef, the shared
  converter, in place of its own copy. Its copy skipped only removed
  tracks, so a missing file (#2527) could still be queued from there.
- shuffled() in lib/utils is the one shuffle, replacing three private
  copies (album page, artist page, ArtistCard).
- The album's refetch error now uses text-error-fg. `text-oxblood` is
  not a colour in the theme, so it never rendered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:18:44 -04:00
co-authored by Claude Opus 5.5
parent aaa6fa15d6
commit 918db27fec
14 changed files with 418 additions and 236 deletions
+2 -10
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { shuffled } from '#lib/utils/shuffle.js';
import type { ArtistRef, TrackRef } from '#lib/api/types.js'; import type { ArtistRef, TrackRef } from '#lib/api/types.js';
import { api } from '#lib/api/client.js'; import { api } from '#lib/api/client.js';
import { playQueue, enqueueTracks } from '#lib/player/store.svelte.js'; import { playQueue, enqueueTracks } from '#lib/player/store.svelte.js';
@@ -12,21 +13,12 @@
let queueing = $state(false); let queueing = $state(false);
function shuffle<T>(items: T[]): T[] {
const arr = items.slice();
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
async function onPlayClick(e: MouseEvent) { async function onPlayClick(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const tracks = await api.get<TrackRef[]>(`/api/artists/${artist.id}/tracks`); const tracks = await api.get<TrackRef[]>(`/api/artists/${artist.id}/tracks`);
if (tracks.length === 0) return; if (tracks.length === 0) return;
playQueue(shuffle(tracks), 0); playQueue(shuffled(tracks), 0);
} }
async function onAddToQueue(e: MouseEvent) { async function onAddToQueue(e: MouseEvent) {
@@ -0,0 +1,60 @@
<script lang="ts">
// The header of every page that is one collection of tracks, album and
// playlist (milestone 494): cover, title, a line or two of facts, then
// Play and Shuffle (PlayActions).
import type { Snippet } from 'svelte';
import PlayActions from './PlayActions.svelte';
import Cover from './Cover.svelte';
let {
title,
coverUrl,
coverAlt = '',
playDisabled = false,
onPlay,
onShuffle,
titleExtra,
tools,
children,
actions
}: {
title: string;
coverUrl: string | null | undefined;
coverAlt?: string;
playDisabled?: boolean;
onPlay: () => void;
onShuffle: () => void;
/** Beside the title, e.g. the album's like button. */
titleExtra?: Snippet;
/** Top right: refresh, link, edit, delete. */
tools?: Snippet;
/** The facts under the title. */
children?: Snippet;
/** After Play and Shuffle, e.g. an admin's Refetch cover. */
actions?: Snippet;
} = $props();
</script>
<header class="mb-6 flex items-start gap-4">
<div class="h-32 w-32 shrink-0 overflow-hidden rounded-md md:h-40 md:w-40">
<Cover src={coverUrl ?? undefined} alt={coverAlt} />
</div>
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-start gap-3">
<h1 class="min-w-0 truncate font-display text-2xl font-medium text-text-primary">{title}</h1>
{#if titleExtra}{@render titleExtra()}{/if}
{#if tools}
<div class="ml-auto flex shrink-0 items-center gap-1">{@render tools()}</div>
{/if}
</div>
{#if children}{@render children()}{/if}
<div class="pt-1">
<PlayActions name={title} disabled={playDisabled} {onPlay} {onShuffle}>
{#if actions}{@render actions()}{/if}
</PlayActions>
</div>
</div>
</header>
+4 -6
View File
@@ -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()}
+14
View File
@@ -0,0 +1,14 @@
<script lang="ts">
// The centred column every track-list page sits in: album, playlist,
// Liked, History and Search → Tracks (milestone 494). The playlist page
// had it alone from May 2026; the operator picked it as the page shape
// on 2026-10-08. Shelf and grid pages (Home, Library, Discover, Artist,
// Search) stay full width, since a narrow column would cost them cards.
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<div class="mx-auto w-full max-w-4xl space-y-4 px-4 py-6">
{@render children()}
</div>
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
// Play and Shuffle, the same on every page that has them: album,
// playlist and artist (milestone 494). Play is filled and Shuffle
// outlined. The fill is the accent: the operator's call on 2026-10-08,
// made with the house rule against accent on action buttons stated to
// them.
import type { Snippet } from 'svelte';
import { Play, Shuffle } from 'lucide-svelte';
let {
name,
disabled = false,
onPlay,
onShuffle,
children
}: {
/** What is played, for the buttons' accessible names. */
name: string;
disabled?: boolean;
onPlay: () => void;
onShuffle: () => void;
/** Further actions after Shuffle. */
children?: Snippet;
} = $props();
</script>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
aria-label={`Play ${name}`}
onclick={onPlay}
{disabled}
class="flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-medium
text-text-primary hover:opacity-90 focus-visible:ring-2 focus-visible:ring-accent
disabled:opacity-50"
>
<Play size={16} strokeWidth={1.5} fill="currentColor" />
Play
</button>
<button
type="button"
aria-label={`Shuffle ${name}`}
onclick={onShuffle}
{disabled}
class="flex items-center gap-2 rounded-md border border-border bg-surface px-4 py-2
text-sm font-medium text-text-primary hover:border-accent
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
>
<Shuffle size={16} strokeWidth={1.5} />
Shuffle
</button>
{#if children}{@render children()}{/if}
</div>
+14
View File
@@ -0,0 +1,14 @@
/**
* A shuffled copy of `items` (Fisher–Yates). The input is not touched.
*
* The one copy for every Shuffle control: the album and playlist headers
* and the artist card's play button each had their own before this.
*/
export function shuffled<T>(items: readonly T[]): T[] {
const arr = items.slice();
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
+63 -98
View File
@@ -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,91 +99,68 @@
← {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>
@@ -207,4 +172,4 @@
</TrackList> </TrackList>
{/if} {/if}
{/if} {/if}
</div> </PageColumn>
+18 -24
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Play } from 'lucide-svelte'; import PlayActions from '#lib/components/PlayActions.svelte';
import { shuffled } from '#lib/utils/shuffle.js';
import { page } from '$app/state'; import { page } from '$app/state';
import { import {
createArtistQuery, createArtistQuery,
@@ -36,31 +37,25 @@
query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found' query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found'
); );
// Mirrors ArtistCard's play overlay: fetch every track for the artist // Fetch every track for the artist (the server already honours per-user
// (server already honours per-user lidarr_quarantine), shuffle client-side, // lidarr_quarantine) and start the queue, in the server's order for Play
// start the queue. Bypassed when the artist has nothing to play. // and shuffled for Shuffle. Bypassed when the artist has nothing to play.
function shuffle<T>(items: T[]): T[] {
const arr = items.slice();
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
let isStartingPlay = $state(false); let isStartingPlay = $state(false);
async function onPlay() { async function start(order: (tracks: TrackRef[]) => TrackRef[]) {
if (isStartingPlay || !id) return; if (isStartingPlay || !id) return;
isStartingPlay = true; isStartingPlay = true;
try { try {
const tracks = await api.get<TrackRef[]>(`/api/artists/${id}/tracks`); const tracks = await api.get<TrackRef[]>(`/api/artists/${id}/tracks`);
if (tracks.length === 0) return; if (tracks.length === 0) return;
playQueue(shuffle(tracks), 0); playQueue(order(tracks), 0);
} finally { } finally {
isStartingPlay = false; isStartingPlay = false;
} }
} }
const onPlay = () => start((tracks) => tracks);
const onShuffle = () => start(shuffled);
</script> </script>
<svelte:head> <svelte:head>
@@ -108,16 +103,15 @@
{/each} {/each}
</ul> </ul>
{/if} {/if}
<div 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}
+45 -1
View File
@@ -1,8 +1,10 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { flushSync } from 'svelte'; import { flushSync } from 'svelte';
import { mockQuery } from '../../../test-utils/query'; import { mockQuery } from '../../../test-utils/query';
import { emptyLikesMock } from '../../../test-utils/mocks/likes'; import { emptyLikesMock } from '../../../test-utils/mocks/likes';
import { apiClientMock } from '../../../test-utils/mocks/client';
import { makeTracks } from '#test-utils/fixtures/track.js';
import type { ArtistDetail, AlbumRef } from '#lib/api/types.js'; import type { ArtistDetail, AlbumRef } from '#lib/api/types.js';
const state = vi.hoisted(() => ({ pageParams: { id: 'abc' } as Record<string, string> })); const state = vi.hoisted(() => ({ pageParams: { id: 'abc' } as Record<string, string> }));
@@ -23,7 +25,20 @@ vi.mock('#lib/api/queries.js', () => ({
vi.mock('#lib/api/likes.js', () => emptyLikesMock()); vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('#lib/api/client.js', () => apiClientMock());
vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: vi.fn(),
enqueueTrack: vi.fn(),
enqueueTracks: vi.fn(),
playRadio: vi.fn(),
playNext: vi.fn(),
player: { current: undefined }
}));
import ArtistPage from './+page.svelte'; import ArtistPage from './+page.svelte';
import { api } from '#lib/api/client.js';
import { playQueue } from '#lib/player/store.svelte.js';
import { import {
createArtistQuery, createArtistQuery,
createSimilarArtistsQuery, createSimilarArtistsQuery,
@@ -132,4 +147,33 @@ describe('artist detail page', () => {
vi.useRealTimers(); vi.useRealTimers();
} }
}); });
// Play and Shuffle are the shared PlayActions (milestone 494). Play keeps
// the server's order; before, the artist's only button was "Play" and it
// shuffled.
describe('Play and Shuffle', () => {
const detail: ArtistDetail = {
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '',
albums: [album('a1', 'First', 2020)], genres: []
};
const tracks = makeTracks(5);
test('Play queues the artist in the server order', async () => {
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue(tracks);
render(ArtistPage);
await fireEvent.click(screen.getByRole('button', { name: 'Play Alice' }));
await waitFor(() => expect(playQueue).toHaveBeenCalledWith(tracks, 0));
});
test('Shuffle queues every track', async () => {
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue(tracks);
render(ArtistPage);
await fireEvent.click(screen.getByRole('button', { name: 'Shuffle Alice' }));
await waitFor(() => expect(playQueue).toHaveBeenCalled());
const [queued] = (playQueue as ReturnType<typeof vi.fn>).mock.calls[0];
expect(queued.map((t: { id: string }) => t.id).sort()).toEqual(tracks.map((t) => t.id).sort());
});
});
}); });
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import PageColumn from '#lib/components/PageColumn.svelte';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { 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>
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import PageColumn from '#lib/components/PageColumn.svelte';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { import {
createLikedTracksInfiniteQuery, createLikedTracksInfiniteQuery,
@@ -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>
+96 -90
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import PageColumn from '#lib/components/PageColumn.svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
@@ -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,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">
@@ -376,4 +382,4 @@
</TrackList> </TrackList>
{/if} {/if}
{/if} {/if}
</div> </PageColumn>
+40 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query'; import { mockQuery } from '../../../test-utils/query';
import { emptyQuarantineMock } from '../../../test-utils/mocks/quarantine'; import { emptyQuarantineMock } from '../../../test-utils/mocks/quarantine';
@@ -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);
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import PageColumn from '#lib/components/PageColumn.svelte';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { 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>