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)', ], }, {