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. */}
{images.length > 1 && (
<>
- {/* Prev / next */}