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).
This commit is contained in:
cucadmuh
2026-05-28 22:47:38 +03:00
committed by GitHub
parent 455aec4def
commit 8443b3d4be
8 changed files with 189 additions and 42 deletions
+9
View File
@@ -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.
@@ -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({
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
</button>
{(() => {
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 ? (
<ArtistTopTrackCover
albumId={song.albumId}
coverArt={coverId}
album={song.album}
/>
) : null;
const albumForCover = topSongAlbumForCover(song, albums);
return albumForCover ? <ArtistTopTrackCover album={albumForCover} /> : null;
})()}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div className="track-title">{song.title}</div>
@@ -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 (
<AlbumCoverArtImage
albumId={albumId}
coverArt={coverArt}
<CoverArtImage
coverRef={coverRef}
displayCssPx={COVER_ARTIST_TOP_TRACK_CSS_PX}
surface="sparse"
surface="dense"
ensurePriority="high"
alt={album}
alt={`${album.name} Cover`}
loading="eager"
decoding="async"
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
/>
);
@@ -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' },
]);
});
});
@@ -0,0 +1,75 @@
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
export type TopSongAlbumCoverSource = Pick<SubsonicAlbum, 'id' | 'coverArt' | 'name'>;
export type AlbumCoverWarmRow = { id: string; coverArt?: string | null };
function pushAlbumWarmRow(
out: AlbumCoverWarmRow[],
seen: Set<string>,
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<SubsonicSong, 'albumId' | 'album' | 'coverArt'>,
albums: ReadonlyArray<Pick<SubsonicAlbum, 'id' | 'name' | 'coverArt'>>,
): 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<Pick<SubsonicSong, 'albumId' | 'album' | 'coverArt'>>,
albums: ReadonlyArray<Pick<SubsonicAlbum, 'id' | 'name' | 'coverArt'>>,
): AlbumCoverWarmRow[] {
const seen = new Set<string>();
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<Pick<SubsonicSong, 'albumId' | 'album' | 'coverArt'>>,
albums: ReadonlyArray<Pick<SubsonicAlbum, 'id' | 'name' | 'coverArt'>>,
limit: number,
): AlbumCoverWarmRow[] {
const seen = new Set<string>();
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;
}
+1
View File
@@ -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)',
],
},
{
+1 -1
View File
@@ -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<SubsonicSong, 'id' | 'coverArt' | 'albumId' | 'album' | 'discNumber'>,
albums: ReadonlyArray<Pick<SubsonicAlbum, 'id' | 'name' | 'coverArt'>>,
+24 -14
View File
@@ -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<string, SubsonicSong[]>();
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 (