Compare commits

...
2 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 918db27fec 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>
2026-10-08 14:18:44 -04:00
bvandeusenandClaude Opus 5.5 aaa6fa15d6 test(web): import history components after their mocks (M494)
HistoryRow now renders LikeButton, and both history test files imported
the component before the likes mock helper. LikeButton then loaded while
emptyLikesMock was still uninitialised, and the suites failed at import.
Every other suite imports components after its mocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:18:44 -04:00
16 changed files with 426 additions and 238 deletions
+2 -10
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { shuffled } from '#lib/utils/shuffle.js';
import type { ArtistRef, TrackRef } from '#lib/api/types.js';
import { api } from '#lib/api/client.js';
import { playQueue, enqueueTracks } from '#lib/player/store.svelte.js';
@@ -12,21 +13,12 @@
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) {
e.preventDefault();
e.stopPropagation();
const tracks = await api.get<TrackRef[]>(`/api/artists/${artist.id}/tracks`);
if (tracks.length === 0) return;
playQueue(shuffle(tracks), 0);
playQueue(shuffled(tracks), 0);
}
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 { coverUrl } from '#lib/media/covers.js';
import TrackListRow from './TrackListRow.svelte';
import Cover from './Cover.svelte';
let { event } = $props<{ event: HistoryEvent }>();
@@ -50,12 +51,9 @@
onActivate={handleClick}
>
{#snippet lead()}
<img
src={coverUrl(event.track.album_id)}
alt=""
class="h-8 w-8 rounded object-cover"
loading="lazy"
/>
<span class="block h-8 w-8 overflow-hidden rounded">
<Cover src={coverUrl(event.track.album_id)} />
</span>
{/snippet}
{#snippet trailing()}
+4 -1
View File
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import HistoryRow from './HistoryRow.svelte';
import type { HistoryEvent } from '#lib/api/history.js';
import { makeTrack } from '#test-utils/fixtures/track.js';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
@@ -27,6 +26,10 @@ vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
// Imported after the mocks: the component pulls in LikeButton, whose
// mocked module must already be initialised.
import HistoryRow from './HistoryRow.svelte';
const sampleTrack = makeTrack({
title: 'Song Title',
album_title: 'Album',
+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">
import { Play, Shuffle } from 'lucide-svelte';
import PageColumn from '#lib/components/PageColumn.svelte';
import { page } from '$app/state';
import { createAlbumQuery } from '#lib/api/queries.js';
import { qk } from '#lib/api/queries.js';
import { pageTitle } from '#lib/branding.js';
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 ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import LikeButton from '#lib/components/LikeButton.svelte';
import { useDelayed } from '#lib/utils/useDelayed.svelte.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 { playQueue } from '#lib/player/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'
);
function onCoverError(e: Event) {
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}
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() {
const data = query.data;
if (isStartingPlay || !data || data.tracks.length === 0) return;
@@ -59,7 +47,7 @@
if (isStartingPlay || !data || data.tracks.length === 0) return;
isStartingPlay = true;
try {
playQueue(shuffleArr(data.tracks), 0);
playQueue(shuffled(data.tracks), 0);
} finally {
isStartingPlay = false;
}
@@ -92,7 +80,7 @@
<title>{pageTitle(query.data?.title ? `Album · ${query.data.title}` : 'Album')}</title>
</svelte:head>
<div class="space-y-6">
<PageColumn>
{#if notFound}
<div role="alert" class="rounded border border-border bg-surface p-4">
<p>Album not found.</p>
@@ -111,91 +99,68 @@
← {album.artist_name}
</a>
<header class="flex flex-col gap-4 md:flex-row md:items-end">
<img
src={album.cover_url}
alt={album.title}
class="h-60 w-60 rounded object-cover"
loading="lazy"
onerror={onCoverError}
/>
<div class="flex-1 space-y-2">
<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" />
</div>
<p>
<a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a>
<CollectionHeader
title={album.title}
coverUrl={album.cover_url}
coverAlt={album.title}
playDisabled={isStartingPlay || album.tracks.length === 0}
{onPlay}
{onShuffle}
>
{#snippet titleExtra()}
<LikeButton entityType="album" entityId={album.id} size="lg" />
{/snippet}
<p>
<a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a>
</p>
<!-- 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>
<!-- 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>
{/if}
{#if album.genres?.length}
<ul class="flex flex-wrap gap-1.5">
{#each album.genres as genre (genre)}
<li>
<a
href={`/library/genres?g=${encodeURIComponent(genre)}`}
class="inline-block rounded-full border border-border px-2.5 py-0.5 text-xs
text-text-secondary hover:bg-surface-hover hover:text-text-primary
focus-visible:ring-2 focus-visible:ring-accent"
>
{genre}
</a>
</li>
{/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">
{/if}
{#if album.genres?.length}
<ul class="flex flex-wrap gap-1.5">
{#each album.genres as genre (genre)}
<li>
<a
href={`/library/genres?g=${encodeURIComponent(genre)}`}
class="inline-block rounded-full border border-border px-2.5 py-0.5 text-xs
text-text-secondary hover:bg-surface-hover hover:text-text-primary
focus-visible:ring-2 focus-visible:ring-accent"
>
{genre}
</a>
</li>
{/each}
</ul>
{/if}
<p class="text-sm text-text-secondary">
{album.track_count} {album.track_count === 1 ? 'track' : 'tracks'}
· {formatDuration(album.duration_sec)}
</p>
{#snippet actions()}
{#if isAdmin}
<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"
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"
>
<Play size={16} strokeWidth={1.5} fill="currentColor" />
Play
Refetch cover
</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}
<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}
{#if refetchError}
<span class="text-sm text-error-fg">{refetchError}</span>
{/if}
{/snippet}
</CollectionHeader>
{#if album.tracks.length === 0}
<p class="text-text-secondary">This album has no tracks.</p>
@@ -207,4 +172,4 @@
</TrackList>
{/if}
{/if}
</div>
</PageColumn>
+18 -24
View File
@@ -1,5 +1,6 @@
<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 {
createArtistQuery,
@@ -36,31 +37,25 @@
query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found'
);
// Mirrors ArtistCard's play overlay: fetch every track for the artist
// (server already honours per-user lidarr_quarantine), shuffle client-side,
// start the queue. 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;
}
// Fetch every track for the artist (the server already honours per-user
// lidarr_quarantine) and start the queue, in the server's order for Play
// and shuffled for Shuffle. Bypassed when the artist has nothing to play.
let isStartingPlay = $state(false);
async function onPlay() {
async function start(order: (tracks: TrackRef[]) => TrackRef[]) {
if (isStartingPlay || !id) return;
isStartingPlay = true;
try {
const tracks = await api.get<TrackRef[]>(`/api/artists/${id}/tracks`);
if (tracks.length === 0) return;
playQueue(shuffle(tracks), 0);
playQueue(order(tracks), 0);
} finally {
isStartingPlay = false;
}
}
const onPlay = () => start((tracks) => tracks);
const onShuffle = () => start(shuffled);
</script>
<svelte:head>
@@ -108,16 +103,15 @@
{/each}
</ul>
{/if}
<div class="mt-3">
<PlayActions
name={detail.name}
disabled={isStartingPlay || detail.album_count === 0}
{onPlay}
{onShuffle}
/>
</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>
{#if detail.albums.length === 0}
+45 -1
View File
@@ -1,8 +1,10 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { flushSync } from 'svelte';
import { mockQuery } from '../../../test-utils/query';
import { emptyLikesMock } from '../../../test-utils/mocks/likes';
import { apiClientMock } from '../../../test-utils/mocks/client';
import { makeTracks } from '#test-utils/fixtures/track.js';
import type { ArtistDetail, AlbumRef } from '#lib/api/types.js';
const state = vi.hoisted(() => ({ pageParams: { id: 'abc' } as Record<string, string> }));
@@ -23,7 +25,20 @@ vi.mock('#lib/api/queries.js', () => ({
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('#lib/api/client.js', () => apiClientMock());
vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: vi.fn(),
enqueueTrack: vi.fn(),
enqueueTracks: vi.fn(),
playRadio: vi.fn(),
playNext: vi.fn(),
player: { current: undefined }
}));
import ArtistPage from './+page.svelte';
import { api } from '#lib/api/client.js';
import { playQueue } from '#lib/player/store.svelte.js';
import {
createArtistQuery,
createSimilarArtistsQuery,
@@ -132,4 +147,33 @@ describe('artist detail page', () => {
vi.useRealTimers();
}
});
// Play and Shuffle are the shared PlayActions (milestone 494). Play keeps
// the server's order; before, the artist's only button was "Play" and it
// shuffled.
describe('Play and Shuffle', () => {
const detail: ArtistDetail = {
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '',
albums: [album('a1', 'First', 2020)], genres: []
};
const tracks = makeTracks(5);
test('Play queues the artist in the server order', async () => {
(createArtistQuery as ReturnType<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">
import PageColumn from '#lib/components/PageColumn.svelte';
import { pageTitle } from '#lib/branding.js';
import { createHistoryQuery } from '#lib/api/history.js';
import { groupByDay } from '#lib/utils/dayGroup.js';
@@ -29,7 +30,7 @@
<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">
<h1 class="font-display text-2xl font-medium text-text-primary">History</h1>
{#if flatEvents.length > 0}
@@ -83,4 +84,4 @@
<p class="py-2 text-center text-sm text-text-secondary">End of history</p>
{/if}
{/if}
</div>
</PageColumn>
+4 -1
View File
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import HistoryPage from './+page.svelte';
import type { HistoryEvent, HistoryResponse } from '#lib/api/history.js';
import { makeTrack } from '#test-utils/fixtures/track.js';
import { emptyLikesMock } from '../../../test-utils/mocks/likes';
@@ -63,6 +62,10 @@ vi.mock('#lib/branding.js', () => ({
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', () => {
beforeEach(() => {
mockData = { pages: [] };
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PageColumn from '#lib/components/PageColumn.svelte';
import { pageTitle } from '#lib/branding.js';
import {
createLikedTracksInfiniteQuery,
@@ -50,7 +51,7 @@
<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">
<h1 class="text-2xl font-semibold">Liked</h1>
{#if artists.length + albums.length + tracks.length > 0}
@@ -176,4 +177,4 @@
{/if}
</section>
{/if}
</div>
</PageColumn>
+96 -90
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PageColumn from '#lib/components/PageColumn.svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
@@ -7,6 +8,9 @@
import { useQueryClient } from '@tanstack/svelte-query';
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 {
createPlaylistQuery,
@@ -75,23 +79,30 @@
}
}
function onPlay(position: number) {
if (!playlistQuery?.data) return;
const data = playlistQuery.data;
// Skip rows where track_id is null (track was removed from library).
const live = data.tracks.filter((t) => t.track_id !== null).map(toTrackRef);
// Find which `live` index corresponds to the clicked position. Some
// positions in the original list may be unavailable (filtered out),
// so the index in the playable list != the playlist position.
const clickedTrackID = data.tracks.find((t) => t.position === position)?.track_id;
const startIdx = live.findIndex((t) => t.id === clickedTrackID);
// Every entry that can be played, in playlist order. playlistTrackToRef
// drops both kinds of dead entry, removed tracks and missing files
// (#2527), so neither is ever queued.
function playable(data: PlaylistDetail): { refs: TrackRef[]; positions: number[] } {
const refs: TrackRef[] = [];
const positions: number[] = [];
for (const row of data.tracks) {
const ref = playlistTrackToRef(row);
if (ref) {
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
// 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
// snapshot re-pulls on total failure.
const variant = data.system_variant;
if (variant != null) {
playQueue(live, Math.max(0, startIdx), {
playQueue(tracks, Math.max(0, startIdx), {
source: variant,
refetch: systemPlaylistRefetch({
variant,
@@ -100,30 +111,25 @@
})
});
} else {
playQueue(live, Math.max(0, startIdx));
playQueue(tracks, Math.max(0, startIdx));
}
}
function toTrackRef(t: {
track_id: string | null;
album_id: string | null;
artist_id: string | null;
title: string;
album_title: string;
artist_name: string;
duration_sec: number;
stream_url: string | null;
}): TrackRef {
return {
id: t.track_id!,
title: t.title,
album_id: t.album_id ?? '',
album_title: t.album_title,
artist_id: t.artist_id ?? '',
artist_name: t.artist_name,
duration_sec: t.duration_sec,
stream_url: t.stream_url ?? ''
} as unknown as TrackRef;
function onPlay(position: number) {
if (!playlistQuery?.data) return;
const data = playlistQuery.data;
const { refs, positions } = playable(data);
startQueue(data, refs, positions.indexOf(position));
}
function onPlayAll() {
if (!playlistQuery?.data) return;
startQueue(playlistQuery.data, playable(playlistQuery.data).refs, 0);
}
function onShuffle() {
if (!playlistQuery?.data) return;
startQueue(playlistQuery.data, shuffled(playable(playlistQuery.data).refs), 0);
}
let editing = $state(false);
@@ -229,7 +235,7 @@
<title>{pageTitle(playlistQuery?.data?.name ? `Playlist · ${playlistQuery.data.name}` : 'Playlist')}</title>
</svelte:head>
<div class="mx-auto max-w-4xl px-4 py-6">
<PageColumn>
{#if playlistQuery?.isPending}
<p class="text-text-muted">Loading…</p>
{:else if playlistQuery?.isError}
@@ -255,64 +261,64 @@
</div>
{/if}
{#if !editing}
<header class="mb-6 flex items-start gap-4">
<div class="h-32 w-32 shrink-0 overflow-hidden rounded-md bg-surface-hover">
{#if pl.cover_url}
<img src={pl.cover_url} alt="" class="h-full w-full object-cover" />
<CollectionHeader
title={pl.name}
coverUrl={pl.cover_url}
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}
</div>
<div class="min-w-0 flex-1">
<h1 class="truncate text-2xl font-medium text-text-primary">{pl.name}</h1>
{#if pl.description}
<p class="mt-1 text-sm text-text-muted">{pl.description}</p>
{#if isOwner && pl.is_public}
<button
type="button"
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}
<p class="mt-2 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>
</div>
{#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 isOwner && pl.is_public}
<button
type="button"
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>
{#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}
{/snippet}
</CollectionHeader>
{:else}
<section class="mb-6 rounded-md border border-border bg-surface p-4">
<label class="block text-sm font-medium text-text-primary">
@@ -376,4 +382,4 @@
</TrackList>
{/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 { mockQuery } from '../../../test-utils/query';
import { emptyQuarantineMock } from '../../../test-utils/mocks/quarantine';
@@ -45,6 +45,7 @@ vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
import PlaylistDetailPage from './+page.svelte';
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>;
@@ -58,7 +59,45 @@ const ownDetail: PlaylistDetail = {
]
};
afterEach(() => vi.clearAllMocks());
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', () => {
mockedQuery.mockReturnValue(mockQuery({ data: ownDetail }));
render(PlaylistDetailPage);
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PageColumn from '#lib/components/PageColumn.svelte';
import { pageTitle } from '#lib/branding.js';
import { page } from '$app/state';
import { createSearchTracksInfiniteQuery } from '#lib/api/queries.js';
@@ -25,7 +26,7 @@
<svelte:head><title>{pageTitle('Search · Tracks')}</title></svelte:head>
<div class="space-y-4">
<PageColumn>
<header>
<a href={`/search?q=${encodeURIComponent(q)}`} class="text-sm text-text-secondary hover:text-text-primary">
← Back to search
@@ -61,4 +62,4 @@
</button>
{/if}
{/if}
</div>
</PageColumn>