mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
committed by
GitHub
parent
efd85ffde3
commit
58d3bcd695
@@ -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.
|
* **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
|
## [1.45.0] - 2026-05-04
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ const BIO_CLAMP_LINES = 4;
|
|||||||
const artistInfoCache = new Map<string, SubsonicArtistInfo | null>();
|
const artistInfoCache = new Map<string, SubsonicArtistInfo | null>();
|
||||||
const songDetailCache = new Map<string, SubsonicSong | 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 {
|
function isoToParts(iso: string): { month: string; day: string; weekday: string; time: string } | null {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -89,16 +92,21 @@ export default function NowPlayingInfo() {
|
|||||||
const artistId = currentTrack?.artistId || '';
|
const artistId = currentTrack?.artistId || '';
|
||||||
const songId = currentTrack?.id || '';
|
const songId = currentTrack?.id || '';
|
||||||
|
|
||||||
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(
|
// Tuple { id, info } gates rendering on "info matches the current artistId" so
|
||||||
artistId && subsonicServerId
|
// `heroImage` (from info) and `heroCacheKey` (from artistId) can never be from
|
||||||
? artistInfoCache.get(queuePanelCacheKey(subsonicServerId, artistId)) ?? null
|
// different tracks. Otherwise a track switch would render one frame with a
|
||||||
: null,
|
// lagging url under the new key, and CachedImage's IndexedDB would persist
|
||||||
);
|
// the wrong blob under the new key — sticky "previous track" image (#…).
|
||||||
const [songDetail, setSongDetail] = useState<SubsonicSong | null>(
|
const [artistInfoEntry, setArtistInfoEntry] = useState<ArtistInfoEntry | null>(() => {
|
||||||
songId && subsonicServerId
|
if (!artistId || !subsonicServerId) return null;
|
||||||
? songDetailCache.get(queuePanelCacheKey(subsonicServerId, songId)) ?? null
|
const cached = artistInfoCache.get(queuePanelCacheKey(subsonicServerId, artistId));
|
||||||
: null,
|
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 [tourEvents, setTourEvents] = useState<BandsintownEvent[]>([]);
|
||||||
const [tourLoading, setTourLoading] = useState(false);
|
const [tourLoading, setTourLoading] = useState(false);
|
||||||
const [bioExpanded, setBioExpanded] = useState(false);
|
const [bioExpanded, setBioExpanded] = useState(false);
|
||||||
@@ -112,27 +120,29 @@ export default function NowPlayingInfo() {
|
|||||||
|
|
||||||
// Artist bio + image
|
// Artist bio + image
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!subsonicReady || !subsonicServerId || !artistId) { setArtistInfo(null); return; }
|
if (!subsonicReady || !subsonicServerId || !artistId) { setArtistInfoEntry(null); return; }
|
||||||
const cacheKey = queuePanelCacheKey(subsonicServerId, artistId);
|
const cacheKey = queuePanelCacheKey(subsonicServerId, artistId);
|
||||||
const cached = artistInfoCache.get(cacheKey);
|
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;
|
let cancelled = false;
|
||||||
getArtistInfo(artistId)
|
getArtistInfo(artistId)
|
||||||
.then(info => { if (!cancelled) { artistInfoCache.set(cacheKey, info ?? null); setArtistInfo(info ?? null); } })
|
.then(info => { if (!cancelled) { artistInfoCache.set(cacheKey, info ?? null); setArtistInfoEntry({ id: artistId, info: info ?? null }); } })
|
||||||
.catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfo(null); } });
|
.catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfoEntry({ id: artistId, info: null }); } });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [subsonicReady, subsonicServerId, artistId]);
|
}, [subsonicReady, subsonicServerId, artistId]);
|
||||||
|
|
||||||
// Song detail (for OpenSubsonic contributors[])
|
// Song detail (for OpenSubsonic contributors[])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!subsonicReady || !subsonicServerId || !songId) { setSongDetail(null); return; }
|
if (!subsonicReady || !subsonicServerId || !songId) { setSongDetailEntry(null); return; }
|
||||||
const cacheKey = queuePanelCacheKey(subsonicServerId, songId);
|
const cacheKey = queuePanelCacheKey(subsonicServerId, songId);
|
||||||
const cached = songDetailCache.get(cacheKey);
|
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;
|
let cancelled = false;
|
||||||
getSong(songId)
|
getSong(songId)
|
||||||
.then(song => { if (!cancelled) { songDetailCache.set(cacheKey, song ?? null); setSongDetail(song ?? null); } })
|
.then(song => { if (!cancelled) { songDetailCache.set(cacheKey, song ?? null); setSongDetailEntry({ id: songId, song: song ?? null }); } })
|
||||||
.catch(() => { if (!cancelled) { songDetailCache.set(cacheKey, null); setSongDetail(null); } });
|
.catch(() => { if (!cancelled) { songDetailCache.set(cacheKey, null); setSongDetailEntry({ id: songId, song: null }); } });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [subsonicReady, subsonicServerId, songId]);
|
}, [subsonicReady, subsonicServerId, songId]);
|
||||||
|
|
||||||
@@ -147,9 +157,16 @@ export default function NowPlayingInfo() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [enableBandsintown, artistName]);
|
}, [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
|
// Detect whether the (clamped) bio actually overflows so we hide the toggle
|
||||||
// when it would do nothing.
|
// when it would do nothing.
|
||||||
const bio = artistInfo?.biography?.trim() || '';
|
const bio = matchedArtistInfo?.biography?.trim() || '';
|
||||||
const bioClean = bio.replace(/<a [^>]*>.*?<\/a>\.?/gi, '').trim();
|
const bioClean = bio.replace(/<a [^>]*>.*?<\/a>\.?/gi, '').trim();
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = bioRef.current;
|
const el = bioRef.current;
|
||||||
@@ -158,8 +175,8 @@ export default function NowPlayingInfo() {
|
|||||||
}, [bioClean]);
|
}, [bioClean]);
|
||||||
|
|
||||||
const contributorRows = useMemo(
|
const contributorRows = useMemo(
|
||||||
() => buildContributorRows(songDetail, artistName),
|
() => buildContributorRows(matchedSongDetail, artistName),
|
||||||
[songDetail, artistName],
|
[matchedSongDetail, artistName],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!currentTrack) {
|
if (!currentTrack) {
|
||||||
@@ -170,8 +187,9 @@ export default function NowPlayingInfo() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const heroImage = artistInfo?.largeImageUrl || artistInfo?.mediumImageUrl || '';
|
const heroImage =
|
||||||
const heroCacheKey = artistId ? `artistInfo:${artistId}:hero` : '';
|
matchedArtistInfo?.largeImageUrl || matchedArtistInfo?.mediumImageUrl || '';
|
||||||
|
const heroCacheKey = matchedArtistInfo && artistId ? `artistInfo:${artistId}:hero` : '';
|
||||||
|
|
||||||
const visibleTours = showAllTours ? tourEvents : tourEvents.slice(0, TOUR_LIMIT);
|
const visibleTours = showAllTours ? tourEvents : tourEvents.slice(0, TOUR_LIMIT);
|
||||||
const hiddenTourCount = Math.max(0, tourEvents.length - visibleTours.length);
|
const hiddenTourCount = Math.max(0, tourEvents.length - visibleTours.length);
|
||||||
|
|||||||
Reference in New Issue
Block a user