fix(queue-info): show artist image for current track (#732)

* fix(queue-info): pin artist image cache key to matching info, not lagging state

`artistInfo` state and `artistId` updated on different cycles, so on
track change the info panel rendered one frame with the previous
track's `largeImageUrl` under the new `heroCacheKey`. CachedImage's
IndexedDB persisted that mismatched blob under the new key, leaving
every subsequent track stuck on the previous artist's image.

Hold artist info + song detail as `{ id, info }` tuples and gate
render on id-match so `src` and `cacheKey` always come from the
same source.

* docs(changelog): queue info artist image fix (PR #732)
This commit is contained in:
Frank Stellmacher
2026-05-16 02:39:14 +02:00
committed by GitHub
parent efd85ffde3
commit 58d3bcd695
2 changed files with 47 additions and 23 deletions
+6
View File
@@ -553,6 +553,12 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa
* **Lucky Mix** ran many queue edits (`pruneUpcomingToCurrent`, **`playTrack`**, **`enqueue`** batches). Each pushed onto the bounded **`QUEUE_UNDO_MAX`** stack, so the snapshot taken **before** Lucky Mix was usually shifted off — Ctrl+Z only stepped through small edits or could not restore the prior queue. The mix flow now pushes **one** undo snapshot up-front and skips intermediate **`enqueue`** / prune snapshots (**`skipQueueUndo`**) so a single undo restores the queue from immediately **before** Lucky Mix.
### Queue panel Info — artist image now follows the current track
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#732](https://github.com/Psychotoxical/psysonic/pull/732)**
* The Info tab rendered one frame on each track switch with the previous track's artist image URL paired with the new track's cache key — **`CachedImage`**'s IndexedDB then persisted that mismatched blob, so every subsequent track stayed stuck on the previous artist's image. Artist info and song detail are now held as **`{ id, info }`** tuples and the image render is gated on id-match, so source and cache key always come from the same track.
## [1.45.0] - 2026-05-04
## Added
+41 -23
View File
@@ -22,6 +22,9 @@ const BIO_CLAMP_LINES = 4;
const artistInfoCache = new Map<string, SubsonicArtistInfo | null>();
const songDetailCache = new Map<string, SubsonicSong | null>();
type ArtistInfoEntry = { id: string; info: SubsonicArtistInfo | null };
type SongDetailEntry = { id: string; song: SubsonicSong | null };
function isoToParts(iso: string): { month: string; day: string; weekday: string; time: string } | null {
if (!iso) return null;
const d = new Date(iso);
@@ -89,16 +92,21 @@ export default function NowPlayingInfo() {
const artistId = currentTrack?.artistId || '';
const songId = currentTrack?.id || '';
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(
artistId && subsonicServerId
? artistInfoCache.get(queuePanelCacheKey(subsonicServerId, artistId)) ?? null
: null,
);
const [songDetail, setSongDetail] = useState<SubsonicSong | null>(
songId && subsonicServerId
? songDetailCache.get(queuePanelCacheKey(subsonicServerId, songId)) ?? null
: null,
);
// Tuple { id, info } gates rendering on "info matches the current artistId" so
// `heroImage` (from info) and `heroCacheKey` (from artistId) can never be from
// different tracks. Otherwise a track switch would render one frame with a
// lagging url under the new key, and CachedImage's IndexedDB would persist
// the wrong blob under the new key — sticky "previous track" image (#…).
const [artistInfoEntry, setArtistInfoEntry] = useState<ArtistInfoEntry | null>(() => {
if (!artistId || !subsonicServerId) return null;
const cached = artistInfoCache.get(queuePanelCacheKey(subsonicServerId, artistId));
return cached === undefined ? null : { id: artistId, info: cached };
});
const [songDetailEntry, setSongDetailEntry] = useState<SongDetailEntry | null>(() => {
if (!songId || !subsonicServerId) return null;
const cached = songDetailCache.get(queuePanelCacheKey(subsonicServerId, songId));
return cached === undefined ? null : { id: songId, song: cached };
});
const [tourEvents, setTourEvents] = useState<BandsintownEvent[]>([]);
const [tourLoading, setTourLoading] = useState(false);
const [bioExpanded, setBioExpanded] = useState(false);
@@ -112,27 +120,29 @@ export default function NowPlayingInfo() {
// Artist bio + image
useEffect(() => {
if (!subsonicReady || !subsonicServerId || !artistId) { setArtistInfo(null); return; }
if (!subsonicReady || !subsonicServerId || !artistId) { setArtistInfoEntry(null); return; }
const cacheKey = queuePanelCacheKey(subsonicServerId, artistId);
const cached = artistInfoCache.get(cacheKey);
if (cached !== undefined) { setArtistInfo(cached); return; }
if (cached !== undefined) { setArtistInfoEntry({ id: artistId, info: cached }); return; }
setArtistInfoEntry(null);
let cancelled = false;
getArtistInfo(artistId)
.then(info => { if (!cancelled) { artistInfoCache.set(cacheKey, info ?? null); setArtistInfo(info ?? null); } })
.catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfo(null); } });
.then(info => { if (!cancelled) { artistInfoCache.set(cacheKey, info ?? null); setArtistInfoEntry({ id: artistId, info: info ?? null }); } })
.catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfoEntry({ id: artistId, info: null }); } });
return () => { cancelled = true; };
}, [subsonicReady, subsonicServerId, artistId]);
// Song detail (for OpenSubsonic contributors[])
useEffect(() => {
if (!subsonicReady || !subsonicServerId || !songId) { setSongDetail(null); return; }
if (!subsonicReady || !subsonicServerId || !songId) { setSongDetailEntry(null); return; }
const cacheKey = queuePanelCacheKey(subsonicServerId, songId);
const cached = songDetailCache.get(cacheKey);
if (cached !== undefined) { setSongDetail(cached); return; }
if (cached !== undefined) { setSongDetailEntry({ id: songId, song: cached }); return; }
setSongDetailEntry(null);
let cancelled = false;
getSong(songId)
.then(song => { if (!cancelled) { songDetailCache.set(cacheKey, song ?? null); setSongDetail(song ?? null); } })
.catch(() => { if (!cancelled) { songDetailCache.set(cacheKey, null); setSongDetail(null); } });
.then(song => { if (!cancelled) { songDetailCache.set(cacheKey, song ?? null); setSongDetailEntry({ id: songId, song: song ?? null }); } })
.catch(() => { if (!cancelled) { songDetailCache.set(cacheKey, null); setSongDetailEntry({ id: songId, song: null }); } });
return () => { cancelled = true; };
}, [subsonicReady, subsonicServerId, songId]);
@@ -147,9 +157,16 @@ export default function NowPlayingInfo() {
return () => { cancelled = true; };
}, [enableBandsintown, artistName]);
// Only consume info that belongs to the current track — never render with a
// stale entry from the previous track.
const matchedArtistInfo =
artistInfoEntry && artistInfoEntry.id === artistId ? artistInfoEntry.info : null;
const matchedSongDetail =
songDetailEntry && songDetailEntry.id === songId ? songDetailEntry.song : null;
// Detect whether the (clamped) bio actually overflows so we hide the toggle
// when it would do nothing.
const bio = artistInfo?.biography?.trim() || '';
const bio = matchedArtistInfo?.biography?.trim() || '';
const bioClean = bio.replace(/<a [^>]*>.*?<\/a>\.?/gi, '').trim();
useLayoutEffect(() => {
const el = bioRef.current;
@@ -158,8 +175,8 @@ export default function NowPlayingInfo() {
}, [bioClean]);
const contributorRows = useMemo(
() => buildContributorRows(songDetail, artistName),
[songDetail, artistName],
() => buildContributorRows(matchedSongDetail, artistName),
[matchedSongDetail, artistName],
);
if (!currentTrack) {
@@ -170,8 +187,9 @@ export default function NowPlayingInfo() {
);
}
const heroImage = artistInfo?.largeImageUrl || artistInfo?.mediumImageUrl || '';
const heroCacheKey = artistId ? `artistInfo:${artistId}:hero` : '';
const heroImage =
matchedArtistInfo?.largeImageUrl || matchedArtistInfo?.mediumImageUrl || '';
const heroCacheKey = matchedArtistInfo && artistId ? `artistInfo:${artistId}:hero` : '';
const visibleTours = showAllTours ? tourEvents : tourEvents.slice(0, TOUR_LIMIT);
const hiddenTourCount = Math.max(0, tourEvents.length - visibleTours.length);