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
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user