mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -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
|
## [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.
|
> **🙏 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 { useTranslation } from 'react-i18next';
|
||||||
import { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
|
import { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
|
||||||
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||||
import { resolveArtistPageSongCoverArtId } from '../../cover/resolveCoverArtId';
|
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { usePreviewStore } from '../../store/previewStore';
|
import { usePreviewStore } from '../../store/previewStore';
|
||||||
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
||||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||||
import ArtistTopTrackCover from './ArtistTopTrackCover';
|
import ArtistTopTrackCover from './ArtistTopTrackCover';
|
||||||
|
import { topSongAlbumForCover } from './topSongAlbumForCover';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
topSongs: SubsonicSong[];
|
topSongs: SubsonicSong[];
|
||||||
@@ -95,17 +95,8 @@ export default function ArtistDetailTopTracks({
|
|||||||
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
||||||
</button>
|
</button>
|
||||||
{(() => {
|
{(() => {
|
||||||
const albumRow = song.albumId
|
const albumForCover = topSongAlbumForCover(song, albums);
|
||||||
? albums.find(a => a.id === song.albumId)
|
return albumForCover ? <ArtistTopTrackCover album={albumForCover} /> : null;
|
||||||
: 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;
|
|
||||||
})()}
|
})()}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
<div className="track-title">{song.title}</div>
|
<div className="track-title">{song.title}</div>
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
import React from 'react';
|
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 { 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 (
|
return (
|
||||||
<AlbumCoverArtImage
|
<CoverArtImage
|
||||||
albumId={albumId}
|
coverRef={coverRef}
|
||||||
coverArt={coverArt}
|
|
||||||
displayCssPx={COVER_ARTIST_TOP_TRACK_CSS_PX}
|
displayCssPx={COVER_ARTIST_TOP_TRACK_CSS_PX}
|
||||||
surface="sparse"
|
surface="dense"
|
||||||
ensurePriority="high"
|
ensurePriority="high"
|
||||||
alt={album}
|
alt={`${album.name} Cover`}
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
|
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;
|
||||||
|
}
|
||||||
@@ -139,6 +139,7 @@ const CONTRIBUTOR_ENTRIES = [
|
|||||||
'Analytics: native advanced library backfill coordinator — UI stays responsive on large libraries (PR #881)',
|
'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: 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)',
|
'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)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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}. */
|
/** @deprecated Alias for {@link resolveSongFetchCoverArtId}. */
|
||||||
export const resolveSubsonicSongCoverArtId = resolveSongFetchCoverArtId;
|
export const resolveSubsonicSongCoverArtId = resolveSongFetchCoverArtId;
|
||||||
|
|
||||||
|
/** @deprecated Top tracks use album row `id` + `coverArt` like AlbumCard. */
|
||||||
export function resolveArtistPageSongFetchCoverArtId(
|
export function resolveArtistPageSongFetchCoverArtId(
|
||||||
song: Pick<SubsonicSong, 'id' | 'coverArt' | 'albumId' | 'album' | 'discNumber'>,
|
song: Pick<SubsonicSong, 'id' | 'coverArt' | 'albumId' | 'album' | 'discNumber'>,
|
||||||
albums: ReadonlyArray<Pick<SubsonicAlbum, 'id' | 'name' | 'coverArt'>>,
|
albums: ReadonlyArray<Pick<SubsonicAlbum, 'id' | 'name' | 'coverArt'>>,
|
||||||
|
|||||||
+24
-14
@@ -44,8 +44,10 @@ import ArtistDetailSimilarArtists from '../components/artistDetail/ArtistDetailS
|
|||||||
import ArtistCard from '../components/nowPlaying/ArtistCard';
|
import ArtistCard from '../components/nowPlaying/ArtistCard';
|
||||||
import LosslessModeBanner from '../components/LosslessModeBanner';
|
import LosslessModeBanner from '../components/LosslessModeBanner';
|
||||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
import { albumGridWarmCovers, COVER_DENSE_GRID_MIN_CELL_CSS_PX, GRID_COVER_WARM_LIMIT } from '../cover/layoutSizes';
|
||||||
import { rememberAlbumDistinctDiscCovers } from '../cover/ref';
|
import { artistDetailCoverWarmAlbums } from '../components/artistDetail/topSongAlbumForCover';
|
||||||
|
import { useLibraryCoverPrefetch } from '../cover/useLibraryCoverPrefetch';
|
||||||
|
import { useWarmGridCovers } from '../hooks/useWarmGridCovers';
|
||||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
||||||
import { sortArtistAlbumsByYear } from '../utils/library/sortArtistAlbums';
|
import { sortArtistAlbumsByYear } from '../utils/library/sortArtistAlbums';
|
||||||
@@ -215,18 +217,26 @@ export default function ArtistDetail() {
|
|||||||
setHeaderCoverFailed(false);
|
setHeaderCoverFailed(false);
|
||||||
}, [coverId, coverRevision, id]);
|
}, [coverId, coverRevision, id]);
|
||||||
|
|
||||||
useEffect(() => {
|
const artistCoverWarmAlbums = useMemo(
|
||||||
const byAlbum = new Map<string, SubsonicSong[]>();
|
() => artistDetailCoverWarmAlbums(topSongs, albums, GRID_COVER_WARM_LIMIT),
|
||||||
for (const song of topSongs) {
|
[topSongs, albums],
|
||||||
const albumId = song.albumId?.trim();
|
);
|
||||||
if (!albumId) continue;
|
useWarmGridCovers(artistCoverWarmAlbums, COVER_DENSE_GRID_MIN_CELL_CSS_PX, {
|
||||||
if (!byAlbum.has(albumId)) byAlbum.set(albumId, []);
|
enabled: artistCoverWarmAlbums.length > 0,
|
||||||
byAlbum.get(albumId)!.push(song);
|
limit: GRID_COVER_WARM_LIMIT,
|
||||||
}
|
surface: 'dense',
|
||||||
for (const [albumId, songs] of byAlbum) {
|
});
|
||||||
rememberAlbumDistinctDiscCovers(albumId, songs);
|
useLibraryCoverPrefetch(
|
||||||
}
|
[
|
||||||
}, [topSongs]);
|
{
|
||||||
|
albums: artistCoverWarmAlbums.slice(0, 24),
|
||||||
|
limit: 24,
|
||||||
|
priority: 'high',
|
||||||
|
surface: 'dense',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[artistCoverWarmAlbums],
|
||||||
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user