feat(covers): logo fallback for broken cover art images

CachedImage now shows /logo-psysonic.png when an image fails to load
instead of a broken icon. The fallback switches to object-fit: contain
with a card background so the logo stays proportional inside the container.

If the caller provides a custom onError (e.g. to hide the element), that
handler is used instead. A separate fallbackSrc state prevents React from
re-writing the broken URL on re-renders. The DOM-level onerror = null
guard prevents any infinite error loop.

extractCoverColors skips color extraction when the logo fallback is
active, falling back to the theme accent color instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-08 13:25:25 +02:00
parent c7adb599ee
commit 0119e27f6d
2 changed files with 26 additions and 3 deletions
+24 -3
View File
@@ -26,8 +26,9 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
return fallbackToFetch ? (resolved || fetchUrl) : resolved; return fallbackToFetch ? (resolved || fetchUrl) : resolved;
} }
export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) { export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...props }: CachedImageProps) {
const [inView, setInView] = useState(false); const [inView, setInView] = useState(false);
const [fallbackSrc, setFallbackSrc] = useState<string | undefined>(undefined);
const imgRef = useRef<HTMLImageElement>(null); const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => { useEffect(() => {
@@ -49,14 +50,34 @@ export default function CachedImage({ src, cacheKey, style, onLoad, ...props }:
// 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(() => { useEffect(() => {
setLoaded(false); setLoaded(false);
setFallbackSrc(undefined);
}, [cacheKey]); }, [cacheKey]);
const handleError = (e: React.SyntheticEvent<HTMLImageElement>) => {
if (onError) {
// Caller wants custom error handling (e.g. hide the element)
onError(e);
} else {
// Nullify the DOM-level handler first to prevent any infinite loop
e.currentTarget.onerror = null;
setFallbackSrc('/logo-psysonic.png');
}
};
const isFallback = fallbackSrc !== undefined;
const finalSrc = fallbackSrc ?? (resolvedSrc || undefined);
const fallbackStyle: React.CSSProperties = isFallback
? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' }
: {};
return ( return (
<img <img
ref={imgRef} ref={imgRef}
src={resolvedSrc || undefined} src={finalSrc}
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease' }} style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease', ...fallbackStyle }}
onLoad={e => { setLoaded(true); onLoad?.(e); }} onLoad={e => { setLoaded(true); onLoad?.(e); }}
onError={handleError}
{...props} {...props}
/> />
); );
+2
View File
@@ -127,6 +127,8 @@ const MIN_CONTRAST = 4.5;
*/ */
export function extractCoverColors(imageUrl: string): Promise<CoverColors> { export function extractCoverColors(imageUrl: string): Promise<CoverColors> {
if (!imageUrl) return Promise.resolve({ accent: '' }); if (!imageUrl) return Promise.resolve({ accent: '' });
// Logo fallback has no meaningful color — skip extraction and use theme accent
if (imageUrl.includes('logo-psysonic')) return Promise.resolve({ accent: '' });
return new Promise(resolve => { return new Promise(resolve => {
const img = new Image(); const img = new Image();