diff --git a/src/app/components/DirectorySlideshow.tsx b/src/app/components/DirectorySlideshow.tsx index daecf09..5ba7e92 100644 --- a/src/app/components/DirectorySlideshow.tsx +++ b/src/app/components/DirectorySlideshow.tsx @@ -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 { listDirectory, getWebDAVBaseUrl, type EntryInfo } from '../webdav'; 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 later is instantaneous. +const imgCache = new Map(); + +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 { path: string; } export default function DirectorySlideshow({ path }: Props) { const [images, setImages] = useState([]); - 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 [touched, setTouched] = useState(false); - const touchTimer = useRef | undefined>(undefined); + const touchTimer = useRef>(undefined); + const intervalRef = useRef>(undefined); + const pendingRef = useRef(-1); // target we are currently loading toward + const imagesRef = useRef([]); + 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(() => { if (!path) { setImages([]); return; } listDirectory(path) @@ -23,31 +67,48 @@ export default function DirectorySlideshow({ path }: Props) { const ext = e.name.split('.').pop()?.toLowerCase() ?? ''; 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); + 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([])); }, [path]); + // Persist dot position. useEffect(() => { - if (images.length <= 1 || paused) return; - const t = setInterval(() => setIdx(i => (i + 1) % images.length), 4000); - return () => clearInterval(t); - }, [images.length, paused]); + if (!images.length) return; + localStorage.setItem(`slideshow.idx:${path}`, String(dotIdx)); + }, [dotIdx, path, images.length]); + // Auto-advance: fires 4 s after the previous image actually appeared. useEffect(() => { - if (images.length === 0) return; - localStorage.setItem(`slideshow.idx:${path}`, String(idx)); - }, [idx, path, images.length]); + clearInterval(intervalRef.current); + if (images.length <= 1 || paused) return; + intervalRef.current = setInterval(() => { + navigateTo(showIdxRef.current + 1); + }, 4000); + return () => clearInterval(intervalRef.current); + }, [images.length, paused, navigateTo]); useEffect(() => () => clearTimeout(touchTimer.current), []); if (images.length === 0) return null; - - const current = images[idx]; - const prev = () => setIdx(i => (i - 1 + images.length) % images.length); - const next = () => setIdx(i => (i + 1) % images.length); + const currentImage = images[showIdx]; + if (!currentImage) return null; const handleTouch = () => { setTouched(true); @@ -60,42 +121,44 @@ export default function DirectorySlideshow({ path }: Props) { return (
+ {/* No key prop — keep the element mounted across slides so the browser + applies the cached src synchronously with no blank flash. */} {current.name} {images.length > 1 && ( <> - {/* Prev / next */}
- -
- {/* Pause / play — centered */}
- {/* Dots */}
{images.map((_, i) => (