feat(DirectorySlideshow): enhance image preloading and navigation logic for improved performance
This commit is contained in:
parent
3557b3ab22
commit
24ea5594b6
|
|
@ -1,19 +1,63 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, Pause, Play } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Pause, Play } from 'lucide-react';
|
||||||
import { listDirectory, getWebDAVBaseUrl, type EntryInfo } from '../webdav';
|
import { listDirectory, getWebDAVBaseUrl, type EntryInfo } from '../webdav';
|
||||||
import { IMAGE_EXTS } from './MediaEntry';
|
import { IMAGE_EXTS } from './MediaEntry';
|
||||||
|
|
||||||
|
// Module-level cache: persists for the lifetime of the page session.
|
||||||
|
// Preloading via a detached Image warms the browser's HTTP cache, so setting
|
||||||
|
// the same src on a visible <img> later is instantaneous.
|
||||||
|
const imgCache = new Map<string, HTMLImageElement>();
|
||||||
|
|
||||||
|
function preloadImage(url: string, onLoad?: () => void) {
|
||||||
|
let el = imgCache.get(url);
|
||||||
|
if (!el) {
|
||||||
|
el = new Image();
|
||||||
|
imgCache.set(url, el);
|
||||||
|
el.src = url;
|
||||||
|
}
|
||||||
|
if (el.complete) {
|
||||||
|
onLoad?.();
|
||||||
|
} else if (onLoad) {
|
||||||
|
el.addEventListener('load', onLoad, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DirectorySlideshow({ path }: Props) {
|
export default function DirectorySlideshow({ path }: Props) {
|
||||||
const [images, setImages] = useState<EntryInfo[]>([]);
|
const [images, setImages] = useState<EntryInfo[]>([]);
|
||||||
const [idx, setIdx] = useState(0);
|
const [dotIdx, setDotIdx] = useState(0); // dot indicator (advances immediately)
|
||||||
|
const [showIdx, setShowIdx] = useState(0); // displayed image (advances when loaded)
|
||||||
const [paused, setPaused] = useState(() => localStorage.getItem('slideshow.paused') === '1');
|
const [paused, setPaused] = useState(() => localStorage.getItem('slideshow.paused') === '1');
|
||||||
const [touched, setTouched] = useState(false);
|
const [touched, setTouched] = useState(false);
|
||||||
const touchTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
||||||
|
|
||||||
|
const touchTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||||
|
const pendingRef = useRef(-1); // target we are currently loading toward
|
||||||
|
const imagesRef = useRef<EntryInfo[]>([]);
|
||||||
|
const showIdxRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => { imagesRef.current = images; }, [images]);
|
||||||
|
useEffect(() => { showIdxRef.current = showIdx; }, [showIdx]);
|
||||||
|
|
||||||
|
// Navigate to target: dots move immediately, image waits until loaded.
|
||||||
|
const navigateTo = useCallback((target: number) => {
|
||||||
|
const imgs = imagesRef.current;
|
||||||
|
if (!imgs.length) return;
|
||||||
|
target = ((target % imgs.length) + imgs.length) % imgs.length;
|
||||||
|
pendingRef.current = target;
|
||||||
|
setDotIdx(target);
|
||||||
|
preloadImage(getWebDAVBaseUrl() + imgs[target].path, () => {
|
||||||
|
if (pendingRef.current === target) {
|
||||||
|
setShowIdx(target);
|
||||||
|
showIdxRef.current = target;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch directory listing and preload all images eagerly.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!path) { setImages([]); return; }
|
if (!path) { setImages([]); return; }
|
||||||
listDirectory(path)
|
listDirectory(path)
|
||||||
|
|
@ -23,31 +67,48 @@ export default function DirectorySlideshow({ path }: Props) {
|
||||||
const ext = e.name.split('.').pop()?.toLowerCase() ?? '';
|
const ext = e.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
return IMAGE_EXTS.has(ext);
|
return IMAGE_EXTS.has(ext);
|
||||||
});
|
});
|
||||||
|
const base = getWebDAVBaseUrl();
|
||||||
|
// Warm the cache for every image — no callback, fire and forget.
|
||||||
|
imgs.forEach(img => preloadImage(base + img.path));
|
||||||
setImages(imgs);
|
setImages(imgs);
|
||||||
|
|
||||||
const saved = parseInt(localStorage.getItem(`slideshow.idx:${path}`) ?? '0', 10);
|
const saved = parseInt(localStorage.getItem(`slideshow.idx:${path}`) ?? '0', 10);
|
||||||
setIdx(imgs.length > 0 ? Math.min(saved, imgs.length - 1) : 0);
|
const start = imgs.length > 0 ? Math.min(saved, imgs.length - 1) : 0;
|
||||||
|
// Navigate to the initial index through the normal path so the
|
||||||
|
// onLoad guard is respected even on first display.
|
||||||
|
pendingRef.current = start;
|
||||||
|
setDotIdx(start);
|
||||||
|
preloadImage(base + (imgs[start]?.path ?? ''), () => {
|
||||||
|
if (pendingRef.current === start) {
|
||||||
|
setShowIdx(start);
|
||||||
|
showIdxRef.current = start;
|
||||||
|
}
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(() => setImages([]));
|
.catch(() => setImages([]));
|
||||||
}, [path]);
|
}, [path]);
|
||||||
|
|
||||||
|
// Persist dot position.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (images.length <= 1 || paused) return;
|
if (!images.length) return;
|
||||||
const t = setInterval(() => setIdx(i => (i + 1) % images.length), 4000);
|
localStorage.setItem(`slideshow.idx:${path}`, String(dotIdx));
|
||||||
return () => clearInterval(t);
|
}, [dotIdx, path, images.length]);
|
||||||
}, [images.length, paused]);
|
|
||||||
|
|
||||||
|
// Auto-advance: fires 4 s after the previous image actually appeared.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (images.length === 0) return;
|
clearInterval(intervalRef.current);
|
||||||
localStorage.setItem(`slideshow.idx:${path}`, String(idx));
|
if (images.length <= 1 || paused) return;
|
||||||
}, [idx, path, images.length]);
|
intervalRef.current = setInterval(() => {
|
||||||
|
navigateTo(showIdxRef.current + 1);
|
||||||
|
}, 4000);
|
||||||
|
return () => clearInterval(intervalRef.current);
|
||||||
|
}, [images.length, paused, navigateTo]);
|
||||||
|
|
||||||
useEffect(() => () => clearTimeout(touchTimer.current), []);
|
useEffect(() => () => clearTimeout(touchTimer.current), []);
|
||||||
|
|
||||||
if (images.length === 0) return null;
|
if (images.length === 0) return null;
|
||||||
|
const currentImage = images[showIdx];
|
||||||
const current = images[idx];
|
if (!currentImage) return null;
|
||||||
const prev = () => setIdx(i => (i - 1 + images.length) % images.length);
|
|
||||||
const next = () => setIdx(i => (i + 1) % images.length);
|
|
||||||
|
|
||||||
const handleTouch = () => {
|
const handleTouch = () => {
|
||||||
setTouched(true);
|
setTouched(true);
|
||||||
|
|
@ -60,42 +121,44 @@ export default function DirectorySlideshow({ path }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-3 rounded-lg overflow-hidden">
|
<div className="mb-3 rounded-lg overflow-hidden">
|
||||||
<div className="relative group h-48" onPointerDown={handleTouch}>
|
<div className="relative group h-48" onPointerDown={handleTouch}>
|
||||||
|
{/* No key prop — keep the element mounted across slides so the browser
|
||||||
|
applies the cached src synchronously with no blank flash. */}
|
||||||
<img
|
<img
|
||||||
key={current.path}
|
src={getWebDAVBaseUrl() + currentImage.path}
|
||||||
src={getWebDAVBaseUrl() + current.path}
|
alt={currentImage.name}
|
||||||
alt={current.name}
|
|
||||||
className="w-full h-full object-contain"
|
className="w-full h-full object-contain"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{images.length > 1 && (
|
{images.length > 1 && (
|
||||||
<>
|
<>
|
||||||
{/* Prev / next */}
|
|
||||||
<div className={`absolute inset-0 flex items-center justify-between px-2 pointer-events-none transition-opacity duration-200 ${controlsVisible}`}>
|
<div className={`absolute inset-0 flex items-center justify-between px-2 pointer-events-none transition-opacity duration-200 ${controlsVisible}`}>
|
||||||
<button onClick={prev} className="pointer-events-auto p-1 rounded-full bg-black/50 text-white hover:bg-black/70">
|
<button onClick={() => navigateTo(dotIdx - 1)} className="pointer-events-auto p-1 rounded-full bg-black/50 text-white hover:bg-black/70">
|
||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={next} className="pointer-events-auto p-1 rounded-full bg-black/50 text-white hover:bg-black/70">
|
<button onClick={() => navigateTo(dotIdx + 1)} className="pointer-events-auto p-1 rounded-full bg-black/50 text-white hover:bg-black/70">
|
||||||
<ChevronRight className="w-5 h-5" />
|
<ChevronRight className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pause / play — centered */}
|
|
||||||
<div className={`absolute inset-0 flex items-center justify-center pointer-events-none transition-opacity duration-200 ${controlsVisible}`}>
|
<div className={`absolute inset-0 flex items-center justify-center pointer-events-none transition-opacity duration-200 ${controlsVisible}`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPaused(p => { const next = !p; localStorage.setItem('slideshow.paused', next ? '1' : '0'); return next; })}
|
onClick={() => setPaused(p => {
|
||||||
|
const next = !p;
|
||||||
|
localStorage.setItem('slideshow.paused', next ? '1' : '0');
|
||||||
|
return next;
|
||||||
|
})}
|
||||||
className="pointer-events-auto p-3 rounded-full bg-black/50 text-white hover:bg-black/70"
|
className="pointer-events-auto p-3 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||||
>
|
>
|
||||||
{paused ? <Play className="w-7 h-7" /> : <Pause className="w-7 h-7" />}
|
{paused ? <Play className="w-7 h-7" /> : <Pause className="w-7 h-7" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dots */}
|
|
||||||
<div className={`absolute bottom-0 left-0 right-0 flex justify-center gap-1.5 py-1.5 transition-opacity duration-200 ${controlsVisible}`}>
|
<div className={`absolute bottom-0 left-0 right-0 flex justify-center gap-1.5 py-1.5 transition-opacity duration-200 ${controlsVisible}`}>
|
||||||
{images.map((_, i) => (
|
{images.map((_, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => setIdx(i)}
|
onClick={() => navigateTo(i)}
|
||||||
className={`w-1.5 h-1.5 rounded-full transition-colors ${i === idx ? 'bg-white' : 'bg-white/40'}`}
|
className={`w-1.5 h-1.5 rounded-full transition-colors ${i === dotIdx ? 'bg-white' : 'bg-white/40'}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user