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)
This commit is contained in:
cucadmuh
2026-05-14 21:38:56 +03:00
committed by GitHub
parent b4c8ed4b65
commit ecdbe0cf2a
3 changed files with 40 additions and 13 deletions
+13
View File
@@ -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**. * 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. * 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 ## Removed
### Settings — Animations 3-state setting under Seekbar Style ### 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 `<html>` 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. * Root cause: closing to the tray injects a "pause rendering" snippet that sets `data-psy-native-hidden="true"` on `<html>` 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. * 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 ## [1.45.0] - 2026-05-04
## Added ## Added
+26 -13
View File
@@ -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 { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { acquireUrl, getCachedBlob, releaseUrl, subscribeCoverUpgraded } from '../utils/imageCache'; 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`). */ /** Default IO lead — slightly before visible to reduce scroll-in jitter (tune per `CachedImage`). */
export const DEFAULT_CACHED_IMAGE_PREPARE_MARGIN = '440px'; 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 * Returns a shared, refcounted object URL for a cached image. Multiple
* consumers of the same cacheKey see the exact same URL string, so the * consumers of the same cacheKey see the exact same URL string, so the
@@ -48,13 +51,19 @@ export function useCachedUrl(
const fetchUrlRef = useRef(fetchUrl); const fetchUrlRef = useRef(fetchUrl);
fetchUrlRef.current = fetchUrl; fetchUrlRef.current = fetchUrl;
// Synchronously acquire on first render when the blob is already hot. This // Pair blob URL with the `cacheKey` it belongs to. After `cacheKey` changes,
// makes the very first <img src> a blob URL, avoiding a fetchUrl→blobUrl // React keeps old state for one render — returning that stale blob URL made
// swap that would trigger a redundant network request and decode pass. // `<img src>` point at a released object URL (broken image) until effects ran.
const [resolved, setResolved] = useState(() => fetchUrl ? (acquireUrl(cacheKey) ?? '') : ''); const [resolvedSlice, setResolvedSlice] = useState<ResolvedSlice>(() => {
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 // Tracks whichever cacheKey we currently hold a refcount on, so we know
// exactly what to release on cleanup or when keys change. // exactly what to release on cleanup or when keys change.
const ownedKeyRef = useRef<string | null>(resolved ? cacheKey : null); const ownedKeyRef = useRef<string | null>(
resolvedSlice.key === cacheKey && resolvedSlice.url ? cacheKey : null,
);
const getPriorityRef = useRef(getPriority); const getPriorityRef = useRef(getPriority);
getPriorityRef.current = getPriority; getPriorityRef.current = getPriority;
@@ -70,7 +79,7 @@ export function useCachedUrl(
const currentUrl = fetchUrlRef.current; const currentUrl = fetchUrlRef.current;
if (!currentUrl) { if (!currentUrl) {
release(); release();
setResolved(''); setResolvedSlice({ key: null, url: '' });
return release; return release;
} }
@@ -86,19 +95,19 @@ export function useCachedUrl(
const sync = acquireUrl(cacheKey); const sync = acquireUrl(cacheKey);
if (sync) { if (sync) {
ownedKeyRef.current = cacheKey; ownedKeyRef.current = cacheKey;
setResolved(sync); setResolvedSlice({ key: cacheKey, url: sync });
return release; return release;
} }
// Slow path: fetch (or read from IDB), then acquire. // Slow path: fetch (or read from IDB), then acquire.
setResolved(''); setResolvedSlice({ key: cacheKey, url: '' });
const controller = new AbortController(); const controller = new AbortController();
getCachedBlob(currentUrl, cacheKey, controller.signal, () => getPriorityRef.current?.() ?? 0).then(blob => { getCachedBlob(currentUrl, cacheKey, controller.signal, () => getPriorityRef.current?.() ?? 0).then(blob => {
if (controller.signal.aborted || !blob) return; if (controller.signal.aborted || !blob) return;
const url = acquireUrl(cacheKey); const url = acquireUrl(cacheKey);
if (!url) return; if (!url) return;
ownedKeyRef.current = cacheKey; ownedKeyRef.current = cacheKey;
setResolved(url); setResolvedSlice({ key: cacheKey, url });
}); });
return () => { return () => {
controller.abort(); controller.abort();
@@ -114,7 +123,7 @@ export function useCachedUrl(
const refreshed = acquireUrl(cacheKey); const refreshed = acquireUrl(cacheKey);
if (refreshed) { if (refreshed) {
ownedKeyRef.current = cacheKey; ownedKeyRef.current = cacheKey;
setResolved(refreshed); setResolvedSlice({ key: cacheKey, url: refreshed });
} }
}); });
return () => { return () => {
@@ -123,7 +132,9 @@ export function useCachedUrl(
}; };
}, [cacheKey, fetchUrl, fallbackToFetch]); }, [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({ export default function CachedImage({
@@ -190,7 +201,9 @@ export default function CachedImage({
// Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl // Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
// URL upgrades within the same image — avoids the end-of-load flash. // 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); setLoaded(false);
setFallbackSrc(undefined); setFallbackSrc(undefined);
}, [cacheKey]); }, [cacheKey]);
+1
View File
@@ -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)', '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)', '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)', '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)',
], ],
}, },
{ {