From ecdbe0cf2a29f61e0876e993b8f8f60582052033 Mon Sep 17 00:00:00 2001
From: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
Date: Thu, 14 May 2026 21:38:56 +0300
Subject: [PATCH] fix(player): stale cover blob and load state on track change
(#695)
* fix(player): align cached cover URL with cacheKey on track change
Prevents a one-frame stale blob src (and broken image in the player bar)
when switching tracks; reset CachedImage load state in useLayoutEffect.
* docs: changelog + credits for cover-art track-switch fix (PR #695)
---
CHANGELOG.md | 13 ++++++++++++
src/components/CachedImage.tsx | 39 ++++++++++++++++++++++------------
src/config/settingsCredits.ts | 1 +
3 files changed, 40 insertions(+), 13 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f861c0c..aa2276f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -237,6 +237,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Added eight new dark themes covering the colour families people most commonly ask for: **Obsidian Black**, **Carbon Grey**, **Volcanic Dark**, **Forest Green**, **Violet Haze**, **Copper Oxide**, **Sakura Night**, **Obsidian Gold**.
* Light polish on the existing **AMOLED Black Pure** surface variables so card surfaces no longer collapse onto a pure-black background that read as a single flat slab.
+### Covers / image cache — `useCachedUrl` tied to `cacheKey`; `CachedImage` load gate
+
+**By [@cucadmuh](https://github.com/cucadmuh), PR [#695](https://github.com/Psychotoxical/psysonic/pull/695)**
+
+* **`useCachedUrl`** only returns a shared blob URL when it still matches the **current** `cacheKey`, so a track change does not paint one frame with the **previous** track's object URL after refcount handling (player bar, queue header cover, Now Playing / mobile paths on the hook).
+* **`CachedImage`** clears the opacity **load** gate in **`useLayoutEffect`** when `cacheKey` changes so the first paint after a swap cannot briefly show the new `src` at full opacity before the gate runs.
+
## Removed
### Settings — Animations 3-state setting under Seekbar Style
@@ -371,6 +378,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Root cause: closing to the tray injects a "pause rendering" snippet that sets `data-psy-native-hidden="true"` on `` and pauses every CSS animation. The tray-icon restore path injects the matching "resume rendering" snippet before showing the window — the second-launch restore path (handled by the **single-instance plugin**) was **missing that step**, so route wrappers using `.animate-fade-in` (`animation: fadeIn … both`, starts at `opacity: 0`) stayed frozen invisible.
* Fix: mirror the tray-icon restore path and resume rendering before `show()` in the single-instance callback. Both restore paths are now consistent.
+### Player UI — broken album-art icon when switching tracks
+
+**By [@cucadmuh](https://github.com/cucadmuh), PR [#695](https://github.com/Psychotoxical/psysonic/pull/695)**
+
+* Fixes issue [#606](https://github.com/Psychotoxical/psysonic/issues/606): the small cover in the **player bar** (and other `CachedImage` surfaces) no longer flashes the browser **broken-image** placeholder for a split second when skipping tracks or changing the current queue item.
+
## [1.45.0] - 2026-05-04
## Added
diff --git a/src/components/CachedImage.tsx b/src/components/CachedImage.tsx
index 24b6920f..b4d8a339 100644
--- a/src/components/CachedImage.tsx
+++ b/src/components/CachedImage.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { acquireUrl, getCachedBlob, releaseUrl, subscribeCoverUpgraded } from '../utils/imageCache';
@@ -24,6 +24,9 @@ export const FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM = 1_000_000_000;
/** Default IO lead — slightly before visible to reduce scroll-in jitter (tune per `CachedImage`). */
export const DEFAULT_CACHED_IMAGE_PREPARE_MARGIN = '440px';
+/** Blob URL paired with the `cacheKey` it was acquired for (`useCachedUrl`). */
+type ResolvedSlice = { key: string | null; url: string };
+
/**
* Returns a shared, refcounted object URL for a cached image. Multiple
* consumers of the same cacheKey see the exact same URL string, so the
@@ -48,13 +51,19 @@ export function useCachedUrl(
const fetchUrlRef = useRef(fetchUrl);
fetchUrlRef.current = fetchUrl;
- // Synchronously acquire on first render when the blob is already hot. This
- // makes the very first
a blob URL, avoiding a fetchUrl→blobUrl
- // swap that would trigger a redundant network request and decode pass.
- const [resolved, setResolved] = useState(() => fetchUrl ? (acquireUrl(cacheKey) ?? '') : '');
+ // Pair blob URL with the `cacheKey` it belongs to. After `cacheKey` changes,
+ // React keeps old state for one render — returning that stale blob URL made
+ // `
` point at a released object URL (broken image) until effects ran.
+ const [resolvedSlice, setResolvedSlice] = useState(() => {
+ if (!fetchUrl) return { key: null, url: '' };
+ const sync = acquireUrl(cacheKey);
+ return sync ? { key: cacheKey, url: sync } : { key: null, url: '' };
+ });
// Tracks whichever cacheKey we currently hold a refcount on, so we know
// exactly what to release on cleanup or when keys change.
- const ownedKeyRef = useRef(resolved ? cacheKey : null);
+ const ownedKeyRef = useRef(
+ resolvedSlice.key === cacheKey && resolvedSlice.url ? cacheKey : null,
+ );
const getPriorityRef = useRef(getPriority);
getPriorityRef.current = getPriority;
@@ -70,7 +79,7 @@ export function useCachedUrl(
const currentUrl = fetchUrlRef.current;
if (!currentUrl) {
release();
- setResolved('');
+ setResolvedSlice({ key: null, url: '' });
return release;
}
@@ -86,19 +95,19 @@ export function useCachedUrl(
const sync = acquireUrl(cacheKey);
if (sync) {
ownedKeyRef.current = cacheKey;
- setResolved(sync);
+ setResolvedSlice({ key: cacheKey, url: sync });
return release;
}
// Slow path: fetch (or read from IDB), then acquire.
- setResolved('');
+ setResolvedSlice({ key: cacheKey, url: '' });
const controller = new AbortController();
getCachedBlob(currentUrl, cacheKey, controller.signal, () => getPriorityRef.current?.() ?? 0).then(blob => {
if (controller.signal.aborted || !blob) return;
const url = acquireUrl(cacheKey);
if (!url) return;
ownedKeyRef.current = cacheKey;
- setResolved(url);
+ setResolvedSlice({ key: cacheKey, url });
});
return () => {
controller.abort();
@@ -114,7 +123,7 @@ export function useCachedUrl(
const refreshed = acquireUrl(cacheKey);
if (refreshed) {
ownedKeyRef.current = cacheKey;
- setResolved(refreshed);
+ setResolvedSlice({ key: cacheKey, url: refreshed });
}
});
return () => {
@@ -123,7 +132,9 @@ export function useCachedUrl(
};
}, [cacheKey, fetchUrl, fallbackToFetch]);
- return fallbackToFetch ? (resolved || fetchUrl) : resolved;
+ const matched =
+ resolvedSlice.key === cacheKey && resolvedSlice.url ? resolvedSlice.url : '';
+ return fallbackToFetch ? (matched || fetchUrl) : matched;
}
export default function CachedImage({
@@ -190,7 +201,9 @@ export default function CachedImage({
// Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
// URL upgrades within the same image — avoids the end-of-load flash.
- useEffect(() => {
+ // useLayoutEffect: one paint with `loaded` still true + a stale/wrong `src`
+ // showed a broken-cover flash in the player bar when switching tracks (#606).
+ useLayoutEffect(() => {
setLoaded(false);
setFallbackSrc(undefined);
}, [cacheKey]);
diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts
index 57943539..2a6780bd 100644
--- a/src/config/settingsCredits.ts
+++ b/src/config/settingsCredits.ts
@@ -104,6 +104,7 @@ export const CONTRIBUTORS = [
'Environment upgrade & hot-cache playback — replay via RAM/disk on same-track and queue-end resume, playback-source icon stays correct after resume/undo/gapless, sidebar new-releases 500-id cap merge, Windows tray double-click fix, lazy-loaded routes, rodio 0.22 migration (PR #463)',
'Audio: post-sleep stream recovery (Windows + Linux) with poll-gap-armed stall watchdog; preview seekbar freeze + anti-jump on preview end; remove card hover lift and per-card GPU compositing hints (PR #476)',
'Analysis queue control: prune stale http-backfill / cpu-seed jobs when tracks leave the playback queue, cap loudness backfill warmup to current + next 5 tracks, plus debug counters for diagnostics (PR #480)',
+ 'CachedImage / useCachedUrl: blob URL only for matching cacheKey, layout load reset on key change; fixes broken player cover flash on track switch (#606) (PR #695)',
],
},
{