From 0119e27f6d7970e47e853a0ffad9f05d72ec2779 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Wed, 8 Apr 2026 13:25:25 +0200 Subject: [PATCH] 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 --- src/components/CachedImage.tsx | 27 ++++++++++++++++++++++++--- src/utils/dynamicColors.ts | 2 ++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/components/CachedImage.tsx b/src/components/CachedImage.tsx index f965ef4d..9f534f55 100644 --- a/src/components/CachedImage.tsx +++ b/src/components/CachedImage.tsx @@ -26,8 +26,9 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch 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 [fallbackSrc, setFallbackSrc] = useState(undefined); const imgRef = useRef(null); 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. useEffect(() => { setLoaded(false); + setFallbackSrc(undefined); }, [cacheKey]); + const handleError = (e: React.SyntheticEvent) => { + 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 ( { setLoaded(true); onLoad?.(e); }} + onError={handleError} {...props} /> ); diff --git a/src/utils/dynamicColors.ts b/src/utils/dynamicColors.ts index 2a98f890..bb4d3c8b 100644 --- a/src/utils/dynamicColors.ts +++ b/src/utils/dynamicColors.ts @@ -127,6 +127,8 @@ const MIN_CONTRAST = 4.5; */ export function extractCoverColors(imageUrl: string): Promise { 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 => { const img = new Image();