mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -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 `<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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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 <img src> 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
|
||||
// `<img src>` point at a released object URL (broken image) until effects ran.
|
||||
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
|
||||
// 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);
|
||||
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]);
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user