mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
c365140870
- Discord Rich Presence (opt-in) — requested by @Bewenben (#49) - Bulk offline download for playlists and artist discographies — requested by @Apollosport (#54) - Offline Library filter tabs: All / Albums / Playlists / Discographies with artist grouping - Artist images on Artists overview (opt-in, off by default) — reported by @Apollosport (#53) - Image lazy loading via IntersectionObserver (300px margin) across all pages - Fix: crossfade no longer triggers on manual track skip — reported by @netherguy4 (#35) - Fix: playlist offline cache now stored as single entry (not per-album) - Fix: image cache AbortController no longer blocks IDB writes - Update toast: experimental auto-update hint + GH download link always visible (Win/Mac) - Queue tech strip: genre removed - Facebook theme: contrast, opaque badge/back button, queue tab labels - "Save discography offline" label (was "Download discography") - Fix: clearing empty playlists via updatePlaylist.view (Axios empty array workaround) - starredOverrides propagated to AlbumDetail, Favorites, RandomMix Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
64 lines
2.3 KiB
TypeScript
64 lines
2.3 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { getCachedUrl } from '../utils/imageCache';
|
|
|
|
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
|
src: string;
|
|
cacheKey: string;
|
|
}
|
|
|
|
/**
|
|
* @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).
|
|
*/
|
|
export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch = true): string {
|
|
const [resolved, setResolved] = useState('');
|
|
useEffect(() => {
|
|
if (!fetchUrl) { setResolved(''); return; }
|
|
const controller = new AbortController();
|
|
setResolved('');
|
|
getCachedUrl(fetchUrl, cacheKey, controller.signal).then(url => {
|
|
if (!controller.signal.aborted) setResolved(url);
|
|
});
|
|
return () => { controller.abort(); };
|
|
}, [fetchUrl, cacheKey]);
|
|
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
|
|
}
|
|
|
|
export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) {
|
|
const [inView, setInView] = useState(false);
|
|
const imgRef = useRef<HTMLImageElement>(null);
|
|
|
|
useEffect(() => {
|
|
const el = imgRef.current;
|
|
if (!el) return;
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => { if (entry.isIntersecting) { setInView(true); observer.disconnect(); } },
|
|
{ rootMargin: '300px' }, // start fetching 300px before entering viewport
|
|
);
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
// Pass empty string when not yet in view so useCachedUrl skips the fetch entirely.
|
|
const resolvedSrc = useCachedUrl(inView ? src : '', cacheKey);
|
|
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.
|
|
useEffect(() => {
|
|
setLoaded(false);
|
|
}, [cacheKey]);
|
|
|
|
return (
|
|
<img
|
|
ref={imgRef}
|
|
src={resolvedSrc || undefined}
|
|
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease' }}
|
|
onLoad={e => { setLoaded(true); onLoad?.(e); }}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|