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 (