Files
Psychotoxical-psysonic/src/ui/CachedImage.tsx
T

277 lines
10 KiB
TypeScript

import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { acquireUrl, getCachedBlob, releaseUrl, subscribeCoverUpgraded } from '@/utils/imageCache';
import { resolveIntersectionScrollRoot } from '@/utils/ui/resolveIntersectionScrollRoot';
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
cacheKey: string;
/**
* Added to the viewport-based score when waiting for a `getCachedBlob` **network** slot.
* Use to order tiers (e.g. search: artist thumbs before album thumbs) without changing layout.
*/
fetchQueueBias?: number;
/**
* How far beyond the scroll viewport `IntersectionObserver` expands the root.
* Larger = priority / slot ordering updates while the row is still off-screen → less
* empty flash when it hits the viewport. CSS margin syntax (`440px`, `10% 0`, …).
*/
observeRootMargin?: string;
/**
* Optional explicit IO root (e.g. in-page browse viewport id). When omitted,
* the nearest scrolling ancestor is used so priority tracks the visible pane.
*/
observeScrollRootId?: string;
}
/** Search UI: load artist avatars before album covers when many requests compete. */
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
* browser's decoded-image cache hits across instances — critical on
* Chromium/WebView2 (Windows), which keys decode results by URL.
*
* @param fallbackToFetch If true (default), returns the raw fetchUrl while the
* blob is still resolving — useful for <img> tags so the browser starts
* loading immediately. Pass false for CSS background-image consumers that
* should only see a stable blob URL (prevents a double crossfade).
*/
// useCachedUrl is the headless companion of CachedImage — it shares the same
// caching contract and the module-local ResolvedSlice type, and is documented
// alongside the component in src/CLAUDE.md. Intentional co-location; the
// fast-refresh rule is an HMR-only concern.
// eslint-disable-next-line react-refresh/only-export-components
export function useCachedUrl(
fetchUrl: string,
cacheKey: string,
fallbackToFetch = true,
getPriority?: () => number,
): string {
// `buildCoverArtUrl` rotates salt/token on every call — `fetchUrl` is a new
// string each render though the logical image is unchanged (`cacheKey`). If
// `fetchUrl` were an effect dependency, cleanup would run every frame, call
// `releaseUrl`, revoke the blob, and break <img> until onError hides it.
const fetchUrlRef = useRef(fetchUrl);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
fetchUrlRef.current = fetchUrl;
// 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>(
resolvedSlice.key === cacheKey && resolvedSlice.url ? cacheKey : null,
);
const getPriorityRef = useRef(getPriority);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
getPriorityRef.current = getPriority;
useEffect(() => {
const release = () => {
if (ownedKeyRef.current) {
releaseUrl(ownedKeyRef.current);
ownedKeyRef.current = null;
}
};
const currentUrl = fetchUrlRef.current;
if (!currentUrl) {
release();
setResolvedSlice({ key: null, url: '' });
return release;
}
// Same logical image as last run — only `cacheKey` drives this effect.
if (ownedKeyRef.current === cacheKey) {
return release;
}
// Different key than we're currently holding: drop the old one.
release();
// Fast path: blob is hot in memory → grab the shared URL synchronously.
const sync = acquireUrl(cacheKey);
if (sync) {
ownedKeyRef.current = cacheKey;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setResolvedSlice({ key: cacheKey, url: sync });
return release;
}
// Slow path: fetch (or read from IDB), then acquire.
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;
setResolvedSlice({ key: cacheKey, url });
});
return () => {
controller.abort();
release();
};
}, [cacheKey]);
useEffect(() => {
if (!fetchUrl || !fallbackToFetch) return;
let cancelled = false;
const unsub = subscribeCoverUpgraded(cacheKey, () => {
if (cancelled) return;
const refreshed = acquireUrl(cacheKey);
if (refreshed) {
ownedKeyRef.current = cacheKey;
setResolvedSlice({ key: cacheKey, url: refreshed });
}
});
return () => {
cancelled = true;
unsub();
};
}, [cacheKey, fetchUrl, fallbackToFetch]);
const matched =
resolvedSlice.key === cacheKey && resolvedSlice.url ? resolvedSlice.url : '';
return fallbackToFetch ? (matched || fetchUrl) : matched;
}
export default function CachedImage({
src,
cacheKey,
fetchQueueBias = 0,
observeRootMargin = DEFAULT_CACHED_IMAGE_PREPARE_MARGIN,
observeScrollRootId,
style,
onLoad,
onError,
...props
}: CachedImageProps) {
const [fallbackSrc, setFallbackSrc] = useState<string | undefined>(undefined);
const imgRef = useRef<HTMLImageElement>(null);
/**
* Drives disk/network waiter ordering only. We intentionally do **not** gate
* `useCachedUrl` on intersection — relying on IO to “arm” loading proved brittle
* (custom scroll roots, content-visibility, horizontal rails) and led to blank covers.
*/
const priorityRef = useRef(0);
const getViewportImagePriority = useCallback(
() => fetchQueueBias + priorityRef.current,
[fetchQueueBias],
);
useEffect(() => {
const el = imgRef.current;
if (!el) return;
const root =
(observeScrollRootId
? (document.getElementById(observeScrollRootId) as Element | null)
: null)
?? resolveIntersectionScrollRoot(el);
const updateFromEntry = (entry: IntersectionObserverEntry) => {
if (entry.isIntersecting) {
const r = entry.boundingClientRect;
const rootEl = entry.rootBounds;
const vh = (rootEl?.height ?? window.innerHeight) || 1;
const originTop = rootEl?.top ?? 0;
const vc = originTop + vh * 0.5;
const cy = r.top + r.height * 0.5;
const dist = Math.abs(cy - vc);
priorityRef.current = entry.intersectionRatio * 1e7 - dist * 1e3;
} else {
priorityRef.current = -1e12;
}
};
const observer = new IntersectionObserver(
entries => { for (const e of entries) updateFromEntry(e); },
{
root: root ?? undefined,
rootMargin: observeRootMargin,
threshold: [0, 0.02, 0.1, 0.25, 0.5, 0.75, 1],
},
);
observer.observe(el);
return () => observer.disconnect();
}, [observeRootMargin, observeScrollRootId]);
// Same as Hero/PlayerBar: show the salted fetch URL while IndexedDB/network resolves,
// then swap to the shared blob URL — avoids an <img> with no src and opacity stuck at 0.
// Priority still applies to the slow path inside getCachedBlob.
const resolvedSrc = useCachedUrl(src, cacheKey, true, getViewportImagePriority);
const [loaded, setLoaded] = useState(false);
// Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
// URL upgrades within the same image — avoids the end-of-load flash.
// 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(() => {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoaded(false);
setFallbackSrc(undefined);
}, [cacheKey]);
const isFallback = fallbackSrc !== undefined;
const finalSrc = fallbackSrc ?? (resolvedSrc || undefined);
// Browsers sometimes skip `load` for cache hits / lazy + horizontal scroll — unstick opacity.
useEffect(() => {
if (!finalSrc) return;
let alive = true;
const id = requestAnimationFrame(() => {
if (!alive) return;
const img = imgRef.current;
if (img?.complete && img.naturalWidth > 0) {
setLoaded(true);
}
});
return () => {
alive = false;
cancelAnimationFrame(id);
};
}, [finalSrc]);
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 fallbackStyle: React.CSSProperties = isFallback
? { objectFit: 'contain', background: 'var(--bg-card, var(--bg-card, #313244))', padding: '15%' }
: {};
return (
<img
ref={imgRef}
src={finalSrc}
style={{ ...style, opacity: loaded ? 1 : 0, ...fallbackStyle }}
onLoad={e => { setLoaded(true); onLoad?.(e); }}
onError={handleError}
{...props}
/>
);
}