From 58d3bcd695f1c05ffe4d930f2325e3de5999b707 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Sat, 16 May 2026 02:39:14 +0200 Subject: [PATCH] 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) --- CHANGELOG.md | 6 +++ src/components/NowPlayingInfo.tsx | 64 ++++++++++++++++++++----------- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e41d869..ac6844ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/components/NowPlayingInfo.tsx b/src/components/NowPlayingInfo.tsx index 3afba970..51b3b2de 100644 --- a/src/components/NowPlayingInfo.tsx +++ b/src/components/NowPlayingInfo.tsx @@ -22,6 +22,9 @@ const BIO_CLAMP_LINES = 4; const artistInfoCache = new Map(); const songDetailCache = new Map(); +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( - artistId && subsonicServerId - ? artistInfoCache.get(queuePanelCacheKey(subsonicServerId, artistId)) ?? null - : null, - ); - const [songDetail, setSongDetail] = useState( - 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(() => { + if (!artistId || !subsonicServerId) return null; + const cached = artistInfoCache.get(queuePanelCacheKey(subsonicServerId, artistId)); + return cached === undefined ? null : { id: artistId, info: cached }; + }); + const [songDetailEntry, setSongDetailEntry] = useState(() => { + if (!songId || !subsonicServerId) return null; + const cached = songDetailCache.get(queuePanelCacheKey(subsonicServerId, songId)); + return cached === undefined ? null : { id: songId, song: cached }; + }); const [tourEvents, setTourEvents] = useState([]); 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>\.?/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);