From 8443b3d4be39c23e23c975ec447d4adf8aab304a Mon Sep 17 00:00:00 2001
From: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
Date: Thu, 28 May 2026 22:47:38 +0300
Subject: [PATCH] fix(artist): align top-track covers with album grid cover
path (#886)
* fix(artist): align top-track covers with album grid cover path
Top tracks now resolve album.id + album.coverArt like AlbumCard, use
the same useAlbumCoverRef/CoverArtImage dense pipeline, and batch-warm
covers on page load instead of a custom sparse resolver.
* docs: CHANGELOG and credits for PR #886 artist top-track covers
* fix(artist): match All Albums cover warm tier and prefetch on detail page
Warm top-track and discography covers at dense grid tier (140px) instead of
32px thumb tier so disk peek hits cached WebP. Register high-priority dense
prefetch like All Albums and ensure top-track cells at high priority so dense
defer-until-visible does not stall visible thumbs.
* fix(artist): satisfy tsc for top-track cover warm helpers
Use optional coverArt access in pushAlbumWarmRow and align album pick
types with topSongAlbumForCover (id + name + coverArt).
---
CHANGELOG.md | 9 +++
.../artistDetail/ArtistDetailTopTracks.tsx | 15 +---
.../artistDetail/ArtistTopTrackCover.tsx | 29 ++++---
.../artistDetail/topSongAlbumForCover.test.ts | 62 +++++++++++++++
.../artistDetail/topSongAlbumForCover.ts | 75 +++++++++++++++++++
src/config/settingsCredits.ts | 1 +
src/cover/resolveEntry.ts | 2 +-
src/pages/ArtistDetail.tsx | 38 ++++++----
8 files changed, 189 insertions(+), 42 deletions(-)
create mode 100644 src/components/artistDetail/topSongAlbumForCover.test.ts
create mode 100644 src/components/artistDetail/topSongAlbumForCover.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fe6ce0f7..3059d0a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -467,6 +467,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
+### Artist page — top track thumbnails
+
+**By [@cucadmuh](https://github.com/cucadmuh), PR [#886](https://github.com/Psychotoxical/psysonic/pull/886)**
+
+* **Top Tracks** on the artist page now load cover art through the same album `id` + `coverArt` path and disk warm batch as the albums grid below — fixes slow or missing 32px thumbs that used a separate sparse resolver.
+* Warm/peek uses the album-grid tier (not 32px), top-track rows ensure at high priority, and the page registers the same dense prefetch as All Albums.
+
+
+
## [1.46.0] - 2026-05-18
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
diff --git a/src/components/artistDetail/ArtistDetailTopTracks.tsx b/src/components/artistDetail/ArtistDetailTopTracks.tsx
index c9f1be7c..9d0561e9 100644
--- a/src/components/artistDetail/ArtistDetailTopTracks.tsx
+++ b/src/components/artistDetail/ArtistDetailTopTracks.tsx
@@ -2,13 +2,13 @@ import React, { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
-import { resolveArtistPageSongCoverArtId } from '../../cover/resolveCoverArtId';
import { usePlayerStore } from '../../store/playerStore';
import { usePreviewStore } from '../../store/previewStore';
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
import { songToTrack } from '../../utils/playback/songToTrack';
import { formatTrackTime } from '../../utils/format/formatDuration';
import ArtistTopTrackCover from './ArtistTopTrackCover';
+import { topSongAlbumForCover } from './topSongAlbumForCover';
interface Props {
topSongs: SubsonicSong[];
@@ -95,17 +95,8 @@ export default function ArtistDetailTopTracks({
: }
{(() => {
- const albumRow = song.albumId
- ? albums.find(a => a.id === song.albumId)
- : albums.find(a => a.name === song.album);
- const coverId = resolveArtistPageSongCoverArtId(song, albums);
- return coverId && song.albumId ? (
-
- ) : null;
+ const albumForCover = topSongAlbumForCover(song, albums);
+ return albumForCover ? : null;
})()}
{song.title}
diff --git a/src/components/artistDetail/ArtistTopTrackCover.tsx b/src/components/artistDetail/ArtistTopTrackCover.tsx
index 7d657f4b..128b0972 100644
--- a/src/components/artistDetail/ArtistTopTrackCover.tsx
+++ b/src/components/artistDetail/ArtistTopTrackCover.tsx
@@ -1,24 +1,23 @@
import React from 'react';
-import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
+import { CoverArtImage } from '../../cover/CoverArtImage';
+import { useAlbumCoverRef } from '../../cover/useLibraryCoverRef';
import { COVER_ARTIST_TOP_TRACK_CSS_PX } from '../../cover/layoutSizes';
+import type { TopSongAlbumCoverSource } from './topSongAlbumForCover';
+
+/** 32px album thumb — same cover ref path as {@link AlbumCard} on artist pages. */
+export default function ArtistTopTrackCover({ album }: { album: TopSongAlbumCoverSource }) {
+ const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false });
+ if (!coverRef) return null;
-export default function ArtistTopTrackCover({
- albumId,
- coverArt,
- album,
-}: {
- albumId: string;
- coverArt: string;
- album: string;
-}) {
return (
-
);
diff --git a/src/components/artistDetail/topSongAlbumForCover.test.ts b/src/components/artistDetail/topSongAlbumForCover.test.ts
new file mode 100644
index 00000000..0a106693
--- /dev/null
+++ b/src/components/artistDetail/topSongAlbumForCover.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest';
+import { topSongAlbumForCover, topSongAlbumsForCoverWarm, artistDetailCoverWarmAlbums } from './topSongAlbumForCover';
+
+describe('topSongAlbumForCover', () => {
+ it('uses the artist album row when albumId matches', () => {
+ expect(
+ topSongAlbumForCover(
+ { albumId: 'al-1', album: 'Grid Name', coverArt: 'tr-1' },
+ [{ id: 'al-1', name: 'Grid Name', coverArt: 'cov-grid' }],
+ ),
+ ).toEqual({ id: 'al-1', name: 'Grid Name', coverArt: 'cov-grid' });
+ });
+
+ it('falls back to song fields when the album is not in the discography list', () => {
+ expect(
+ topSongAlbumForCover(
+ { albumId: 'al-feat', album: 'Compilation', coverArt: 'cov-feat' },
+ [{ id: 'al-other', name: 'Other', coverArt: 'cov-other' }],
+ ),
+ ).toEqual({ id: 'al-feat', name: 'Compilation', coverArt: 'cov-feat' });
+ });
+
+ it('returns null without albumId', () => {
+ expect(topSongAlbumForCover({ albumId: '', album: 'X', coverArt: 'c' }, [])).toBeNull();
+ });
+});
+
+describe('topSongAlbumsForCoverWarm', () => {
+ it('dedupes by album id', () => {
+ expect(
+ topSongAlbumsForCoverWarm(
+ [
+ { albumId: 'al-1', album: 'A', coverArt: 'c1' },
+ { albumId: 'al-1', album: 'A', coverArt: 'c1' },
+ { albumId: 'al-2', album: 'B', coverArt: 'c2' },
+ ],
+ [{ id: 'al-1', name: 'A', coverArt: 'cov-a' }],
+ ),
+ ).toEqual([
+ { id: 'al-1', coverArt: 'cov-a' },
+ { id: 'al-2', coverArt: 'c2' },
+ ]);
+ });
+});
+
+describe('artistDetailCoverWarmAlbums', () => {
+ it('lists top-track albums before discography and respects limit', () => {
+ expect(
+ artistDetailCoverWarmAlbums(
+ [{ albumId: 'al-top', album: 'Hit', coverArt: 'c-top' }],
+ [
+ { id: 'al-a', name: 'A', coverArt: 'cov-a' },
+ { id: 'al-b', name: 'B', coverArt: 'cov-b' },
+ ],
+ 2,
+ ),
+ ).toEqual([
+ { id: 'al-top', coverArt: 'c-top' },
+ { id: 'al-a', coverArt: 'cov-a' },
+ ]);
+ });
+});
diff --git a/src/components/artistDetail/topSongAlbumForCover.ts b/src/components/artistDetail/topSongAlbumForCover.ts
new file mode 100644
index 00000000..844e3c9e
--- /dev/null
+++ b/src/components/artistDetail/topSongAlbumForCover.ts
@@ -0,0 +1,75 @@
+import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
+
+export type TopSongAlbumCoverSource = Pick
;
+
+export type AlbumCoverWarmRow = { id: string; coverArt?: string | null };
+
+function pushAlbumWarmRow(
+ out: AlbumCoverWarmRow[],
+ seen: Set,
+ row: { id?: string | null; coverArt?: string | null } | null | undefined,
+ limit: number,
+): void {
+ const id = row?.id?.trim();
+ if (!id || seen.has(id) || out.length >= limit) return;
+ seen.add(id);
+ out.push({ id, coverArt: row?.coverArt });
+}
+
+/**
+ * Album row for cover loading on artist top tracks — same `id` + `coverArt` as
+ * {@link AlbumCard} when the album is in the artist discography; otherwise the
+ * featured-album fallback shape (`albumId` + song `coverArt`).
+ */
+export function topSongAlbumForCover(
+ song: Pick,
+ albums: ReadonlyArray>,
+): TopSongAlbumCoverSource | null {
+ const albumId = song.albumId?.trim();
+ if (!albumId) return null;
+
+ const fromList =
+ albums.find(a => a.id === albumId)
+ ?? albums.find(a => a.name === song.album);
+ if (fromList) return fromList;
+
+ return {
+ id: albumId,
+ name: song.album,
+ coverArt: song.coverArt,
+ };
+}
+
+export function topSongAlbumsForCoverWarm(
+ songs: ReadonlyArray>,
+ albums: ReadonlyArray>,
+): AlbumCoverWarmRow[] {
+ const seen = new Set();
+ const out: AlbumCoverWarmRow[] = [];
+ for (const song of songs) {
+ pushAlbumWarmRow(out, seen, topSongAlbumForCover(song, albums), songs.length);
+ }
+ return out;
+}
+
+/**
+ * Top-track albums first, then discography — same warm list shape as All Albums grids.
+ * Use {@link COVER_DENSE_GRID_MIN_CELL_CSS_PX} for peek/ensure tier (not the 32px thumb size).
+ */
+export function artistDetailCoverWarmAlbums(
+ topSongs: ReadonlyArray>,
+ albums: ReadonlyArray>,
+ limit: number,
+): AlbumCoverWarmRow[] {
+ const seen = new Set();
+ const out: AlbumCoverWarmRow[] = [];
+ for (const song of topSongs) {
+ if (out.length >= limit) break;
+ pushAlbumWarmRow(out, seen, topSongAlbumForCover(song, albums), limit);
+ }
+ for (const album of albums) {
+ if (out.length >= limit) break;
+ pushAlbumWarmRow(out, seen, album, limit);
+ }
+ return out;
+}
diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts
index f332ac94..9cab4a34 100644
--- a/src/config/settingsCredits.ts
+++ b/src/config/settingsCredits.ts
@@ -139,6 +139,7 @@ const CONTRIBUTOR_ENTRIES = [
'Analytics: native advanced library backfill coordinator — UI stays responsive on large libraries (PR #881)',
'Analytics: library backfill scan phase/cursor persistence so advanced indexing can finish large libraries (PR #882)',
'Analytics: Opus waveform/LUFS/enrichment decode via symphonia-adapter-libopus in the analysis pipeline (PR #883)',
+ 'Artist page: top-track thumbnails use the same album cover path and warm batch as the albums grid (PR #886)',
],
},
{
diff --git a/src/cover/resolveEntry.ts b/src/cover/resolveEntry.ts
index 9e4ecaeb..43b22238 100644
--- a/src/cover/resolveEntry.ts
+++ b/src/cover/resolveEntry.ts
@@ -98,10 +98,10 @@ export function coverEntryToRef(
};
}
-/** Artist top tracks: prefer album row `coverArt` when the grid already has it. */
/** @deprecated Alias for {@link resolveSongFetchCoverArtId}. */
export const resolveSubsonicSongCoverArtId = resolveSongFetchCoverArtId;
+/** @deprecated Top tracks use album row `id` + `coverArt` like AlbumCard. */
export function resolveArtistPageSongFetchCoverArtId(
song: Pick,
albums: ReadonlyArray>,
diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx
index 34238af0..e36ba406 100644
--- a/src/pages/ArtistDetail.tsx
+++ b/src/pages/ArtistDetail.tsx
@@ -44,8 +44,10 @@ import ArtistDetailSimilarArtists from '../components/artistDetail/ArtistDetailS
import ArtistCard from '../components/nowPlaying/ArtistCard';
import LosslessModeBanner from '../components/LosslessModeBanner';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
-import { albumGridWarmCovers } from '../cover/layoutSizes';
-import { rememberAlbumDistinctDiscCovers } from '../cover/ref';
+import { albumGridWarmCovers, COVER_DENSE_GRID_MIN_CELL_CSS_PX, GRID_COVER_WARM_LIMIT } from '../cover/layoutSizes';
+import { artistDetailCoverWarmAlbums } from '../components/artistDetail/topSongAlbumForCover';
+import { useLibraryCoverPrefetch } from '../cover/useLibraryCoverPrefetch';
+import { useWarmGridCovers } from '../hooks/useWarmGridCovers';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
import { sortArtistAlbumsByYear } from '../utils/library/sortArtistAlbums';
@@ -215,18 +217,26 @@ export default function ArtistDetail() {
setHeaderCoverFailed(false);
}, [coverId, coverRevision, id]);
- useEffect(() => {
- const byAlbum = new Map();
- for (const song of topSongs) {
- const albumId = song.albumId?.trim();
- if (!albumId) continue;
- if (!byAlbum.has(albumId)) byAlbum.set(albumId, []);
- byAlbum.get(albumId)!.push(song);
- }
- for (const [albumId, songs] of byAlbum) {
- rememberAlbumDistinctDiscCovers(albumId, songs);
- }
- }, [topSongs]);
+ const artistCoverWarmAlbums = useMemo(
+ () => artistDetailCoverWarmAlbums(topSongs, albums, GRID_COVER_WARM_LIMIT),
+ [topSongs, albums],
+ );
+ useWarmGridCovers(artistCoverWarmAlbums, COVER_DENSE_GRID_MIN_CELL_CSS_PX, {
+ enabled: artistCoverWarmAlbums.length > 0,
+ limit: GRID_COVER_WARM_LIMIT,
+ surface: 'dense',
+ });
+ useLibraryCoverPrefetch(
+ [
+ {
+ albums: artistCoverWarmAlbums.slice(0, 24),
+ limit: 24,
+ priority: 'high',
+ surface: 'dense',
+ },
+ ],
+ [artistCoverWarmAlbums],
+ );
if (loading) {
return (