Merge lightbox slideshow mode
github/actions/ci GitHub Actions CI finished: success

Add fullscreen slideshow playback for image-only lightbox sessions, including sequential or random order, idle controls, and slideshow settings.

Include the gentle motion transition polish and no-repeat random navigation refinements from the feature branch.
This commit is contained in:
2026-07-02 17:52:30 +01:00
6 changed files with 592 additions and 44 deletions
+4
View File
@@ -52,6 +52,10 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
library but leave out of background processing for now. library but leave out of background processing for now.
- **Editable folder path** — the folder picker now has an address bar, so you can - **Editable folder path** — the folder picker now has an address bar, so you can
paste a path directly while still using breadcrumbs for quick jumps. paste a path directly while still using breadcrumbs for quick jumps.
- **Slideshow mode** — turn the lightbox into a fullscreen, image-only slideshow
from whatever collection you are already browsing. Videos are skipped, controls
tuck themselves away after a few seconds, and Settings lets you pick the pace
and whether playback follows the current order or goes random.
### Changed ### Changed
+2 -2
View File
@@ -2400,8 +2400,8 @@ pub async fn reset_ai_tags(
(None, false) => { (None, false) => {
let mut total = 0usize; let mut total = 0usize;
for &folder_id in &requested_folder_ids { for &folder_id in &requested_folder_ids {
total += db::reset_ai_tags(&conn, Some(folder_id)) total +=
.map_err(|e| e.to_string())?; db::reset_ai_tags(&conn, Some(folder_id)).map_err(|e| e.to_string())?;
} }
(total, requested_folder_ids) (total, requested_folder_ids)
} }
+451 -16
View File
@@ -1,5 +1,5 @@
import { useEffect, useCallback, useRef, useState } from "react"; import { useEffect, useCallback, useMemo, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence, type Transition } from "framer-motion";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { useGalleryStore, ImageTag, ImageExif, AiRating } from "../store"; import { useGalleryStore, ImageTag, ImageExif, AiRating } from "../store";
@@ -128,6 +128,9 @@ interface ViewTransform {
} }
const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 }; const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 };
const SLIDESHOW_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
const SLIDESHOW_CONTROLS_IDLE_MS = 5000;
const SLIDESHOW_LOAD_MORE_THRESHOLD = 3;
/** Re-anchor the pan so the point at (anchorX, anchorY) — measured from the /** Re-anchor the pan so the point at (anchorX, anchorY) — measured from the
* viewport centre — stays under the cursor across a zoom change. */ * viewport centre — stays under the cursor across a zoom change. */
@@ -158,6 +161,12 @@ export function Lightbox() {
const addToAlbum = useGalleryStore((state) => state.addToAlbum); const addToAlbum = useGalleryStore((state) => state.addToAlbum);
const createAlbum = useGalleryStore((state) => state.createAlbum); const createAlbum = useGalleryStore((state) => state.createAlbum);
const getImageExif = useGalleryStore((state) => state.getImageExif); const getImageExif = useGalleryStore((state) => state.getImageExif);
const loadMoreImages = useGalleryStore((state) => state.loadMoreImages);
const loadedCount = useGalleryStore((state) => state.loadedCount);
const totalImages = useGalleryStore((state) => state.totalImages);
const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds);
const slideshowOrder = useGalleryStore((state) => state.slideshowOrder);
const slideshowTransition = useGalleryStore((state) => state.slideshowTransition);
// Tracks the image id that is currently displayed, used to discard async // Tracks the image id that is currently displayed, used to discard async
// tag mutations that resolve after the user has navigated to another image. // tag mutations that resolve after the user has navigated to another image.
@@ -184,11 +193,65 @@ export function Lightbox() {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [dragRect, setDragRect] = useState<DragRect | null>(null); const [dragRect, setDragRect] = useState<DragRect | null>(null);
const [regionSearching, setRegionSearching] = useState(false); const [regionSearching, setRegionSearching] = useState(false);
const [slideshowActive, setSlideshowActive] = useState(false);
const [slideshowPaused, setSlideshowPaused] = useState(false);
const [slideshowControlsVisible, setSlideshowControlsVisible] = useState(true);
const [slideshowLoadingMore, setSlideshowLoadingMore] = useState(false);
const [slideshowMotionStep, setSlideshowMotionStep] = useState(0);
const lightboxRootRef = useRef<HTMLDivElement>(null);
const imageViewportRef = useRef<HTMLDivElement>(null); const imageViewportRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null); const imgRef = useRef<HTMLImageElement>(null);
const slideshowControlsIdleTimeoutRef = useRef<number | null>(null);
const slideshowPointerRef = useRef<{ x: number; y: number } | null>(null);
const slideshowRandomSeenRef = useRef<Set<number>>(new Set());
const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1; const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1;
const slideshowImages = useMemo(
() => images.filter((image) => image.media_kind === "image"),
[images],
);
const slideshowIndex = selectedImage
? slideshowImages.findIndex((image) => image.id === selectedImage.id)
: -1;
const slideshowPosition = slideshowIndex >= 0 ? slideshowIndex + 1 : Math.min(slideshowImages.length, 1);
const slideshowControlsShown = slideshowControlsVisible;
const slideshowMotionDirections = [
{ x: -1, y: 0.45 },
{ x: 1, y: -0.45 },
{ x: -0.7, y: -0.55 },
{ x: 0.7, y: 0.55 },
];
const slideshowMotionDirection =
slideshowMotionDirections[slideshowMotionStep % slideshowMotionDirections.length];
const slideshowImageInitial = { opacity: 0, filter: "blur(8px)" };
const slideshowImageAnimate = { opacity: 1, filter: "blur(0px)" };
const slideshowImageExit = { opacity: 0, filter: "blur(4px)" };
const slideshowImageTransition: Transition = { duration: 0.72, ease: SLIDESHOW_EASE };
const slideshowImageContentInitial =
slideshowTransition === "gentle-motion"
? {
scale: 1.012,
x: -8 * slideshowMotionDirection.x,
y: 8 * slideshowMotionDirection.y,
}
: { scale: 1.012 };
const slideshowImageContentAnimate =
slideshowTransition === "gentle-motion"
? {
scale: 1.026,
x: 8 * slideshowMotionDirection.x,
y: -8 * slideshowMotionDirection.y,
}
: { scale: 1 };
const slideshowImageContentTransition: Transition =
slideshowTransition === "gentle-motion"
? {
duration: slideshowIntervalSeconds + 0.75,
ease: "linear",
}
: { duration: 0.72, ease: SLIDESHOW_EASE };
const canStartSlideshow = slideshowImages.length > 0;
const canFindSimilar = selectedImage?.embedding_status === "ready"; const canFindSimilar = selectedImage?.embedding_status === "ready";
const canSearchRegion = canFindSimilar && selectedImage?.media_kind === "image"; const canSearchRegion = canFindSimilar && selectedImage?.media_kind === "image";
@@ -206,6 +269,106 @@ export function Lightbox() {
setDragRect(null); setDragRect(null);
}, []); }, []);
const showSlideshowControls = useCallback(() => {
if (slideshowControlsIdleTimeoutRef.current !== null) {
window.clearTimeout(slideshowControlsIdleTimeoutRef.current);
slideshowControlsIdleTimeoutRef.current = null;
}
setSlideshowControlsVisible(true);
if (slideshowActive) {
slideshowControlsIdleTimeoutRef.current = window.setTimeout(() => {
setSlideshowControlsVisible(false);
slideshowControlsIdleTimeoutRef.current = null;
}, SLIDESHOW_CONTROLS_IDLE_MS);
}
}, [slideshowActive]);
const exitSlideshow = useCallback(() => {
setSlideshowActive(false);
setSlideshowPaused(false);
setSlideshowControlsVisible(true);
if (document.fullscreenElement === lightboxRootRef.current) {
void document.exitFullscreen().catch(() => undefined);
}
}, []);
const goSlideshow = useCallback(
(direction: -1 | 1, revealControls = true) => {
if (slideshowImages.length === 0) return;
if (slideshowImages.length === 1) {
openImage(slideshowImages[0]);
return;
}
const currentSlideshowIndex = slideshowIndex >= 0 ? slideshowIndex : 0;
let nextIndex =
(currentSlideshowIndex + direction + slideshowImages.length) % slideshowImages.length;
if (slideshowOrder === "random") {
const currentId = slideshowImages[currentSlideshowIndex]?.id;
let candidates = slideshowImages.filter(
(image) => image.id !== currentId && !slideshowRandomSeenRef.current.has(image.id),
);
if (candidates.length === 0) {
slideshowRandomSeenRef.current = new Set(currentId == null ? [] : [currentId]);
candidates = slideshowImages.filter((image) => image.id !== currentId);
}
const nextImage = candidates[Math.floor(Math.random() * candidates.length)];
nextIndex = slideshowImages.findIndex((image) => image.id === nextImage.id);
slideshowRandomSeenRef.current.add(nextImage.id);
}
setSlideshowMotionStep((step) => step + 1);
openImage(slideshowImages[nextIndex]);
if (revealControls) {
showSlideshowControls();
}
},
[openImage, showSlideshowControls, slideshowImages, slideshowIndex, slideshowOrder],
);
const handleSlideshowPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const previous = slideshowPointerRef.current;
const next = { x: event.clientX, y: event.clientY };
slideshowPointerRef.current = next;
if (!previous) {
if (!slideshowControlsVisible) {
showSlideshowControls();
}
return;
}
if (Math.abs(previous.x - next.x) > 2 || Math.abs(previous.y - next.y) > 2) {
showSlideshowControls();
}
},
[showSlideshowControls, slideshowControlsVisible],
);
const startSlideshow = useCallback(() => {
if (!selectedImage || slideshowImages.length === 0) return;
const nextImage =
selectedImage.media_kind === "image"
? selectedImage
: images.slice(Math.max(0, currentIndex + 1)).find((image) => image.media_kind === "image") ??
slideshowImages[0];
if (nextImage.id !== selectedImage.id) {
openImage(nextImage);
}
slideshowRandomSeenRef.current = new Set([nextImage.id]);
setSlideshowMotionStep(0);
exitRegionMode();
setView(IDENTITY_VIEW);
setSlideshowPaused(false);
setSlideshowControlsVisible(true);
slideshowPointerRef.current = null;
setSlideshowActive(true);
void lightboxRootRef.current?.requestFullscreen().catch(() => undefined);
}, [currentIndex, exitRegionMode, images, openImage, selectedImage, slideshowImages]);
// Keep the pan within bounds: when the scaled image overflows the viewport // Keep the pan within bounds: when the scaled image overflows the viewport
// the image edge may not be dragged past the viewport edge; when it fits, // the image edge may not be dragged past the viewport edge; when it fits,
// the pan snaps back to centre on that axis. // the pan snaps back to centre on that axis.
@@ -263,9 +426,71 @@ export function Lightbox() {
if (selectedImage?.ai_tagged_at) setTaggingQueued(false); if (selectedImage?.ai_tagged_at) setTaggingQueued(false);
}, [selectedImage?.ai_tagged_at]); }, [selectedImage?.ai_tagged_at]);
useEffect(() => {
if (selectedImage) return;
setSlideshowActive(false);
setSlideshowPaused(false);
setSlideshowControlsVisible(true);
}, [selectedImage]);
useEffect(() => {
const handleFullscreenChange = () => {
if (!slideshowActive) return;
if (document.fullscreenElement !== lightboxRootRef.current) {
setSlideshowActive(false);
setSlideshowPaused(false);
setSlideshowControlsVisible(true);
}
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, [slideshowActive]);
useEffect(() => {
showSlideshowControls();
return () => {
if (slideshowControlsIdleTimeoutRef.current !== null) {
window.clearTimeout(slideshowControlsIdleTimeoutRef.current);
slideshowControlsIdleTimeoutRef.current = null;
}
};
}, [showSlideshowControls, slideshowActive, slideshowPaused]);
useEffect(() => {
if (!slideshowActive || slideshowPaused || slideshowImages.length <= 1) return;
const timeout = window.setTimeout(() => {
goSlideshow(1, false);
}, slideshowIntervalSeconds * 1000);
return () => window.clearTimeout(timeout);
}, [goSlideshow, selectedImage?.id, slideshowActive, slideshowImages.length, slideshowIntervalSeconds, slideshowPaused]);
useEffect(() => {
if (
!slideshowActive ||
slideshowLoadingMore ||
loadedCount >= totalImages ||
slideshowImages.length === 0 ||
slideshowIndex < slideshowImages.length - SLIDESHOW_LOAD_MORE_THRESHOLD
) {
return;
}
setSlideshowLoadingMore(true);
void loadMoreImages().finally(() => setSlideshowLoadingMore(false));
}, [
loadedCount,
loadMoreImages,
slideshowActive,
slideshowImages.length,
slideshowIndex,
slideshowLoadingMore,
totalImages,
]);
useEffect(() => { useEffect(() => {
const viewport = imageViewportRef.current; const viewport = imageViewportRef.current;
if (!viewport || !selectedImage || selectedImage.media_kind !== "image") return; if (!viewport || !selectedImage || selectedImage.media_kind !== "image" || slideshowActive) return;
const handleWheel = (event: WheelEvent) => { const handleWheel = (event: WheelEvent) => {
if (regionSelectMode) return; // don't zoom during selection if (regionSelectMode) return; // don't zoom during selection
@@ -283,11 +508,35 @@ export function Lightbox() {
viewport.addEventListener("wheel", handleWheel, { passive: false }); viewport.addEventListener("wheel", handleWheel, { passive: false });
return () => viewport.removeEventListener("wheel", handleWheel); return () => viewport.removeEventListener("wheel", handleWheel);
}, [selectedImage, regionSelectMode, clampPan]); }, [selectedImage, regionSelectMode, clampPan, slideshowActive]);
useEffect(() => { useEffect(() => {
const handler = (event: KeyboardEvent) => { const handler = (event: KeyboardEvent) => {
if (!selectedImage) return; if (!selectedImage) return;
if (slideshowActive) {
if (event.key === "Escape") {
event.preventDefault();
exitSlideshow();
return;
}
if (event.key === " ") {
event.preventDefault();
setSlideshowPaused((paused) => !paused);
showSlideshowControls();
return;
}
if (event.key === "ArrowLeft" && !event.shiftKey) {
event.preventDefault();
goSlideshow(-1);
return;
}
if (event.key === "ArrowRight" && !event.shiftKey) {
event.preventDefault();
goSlideshow(1);
return;
}
return;
}
if (event.key === "Escape") { if (event.key === "Escape") {
if (regionSelectMode) { if (regionSelectMode) {
exitRegionMode(); exitRegionMode();
@@ -307,7 +556,19 @@ export function Lightbox() {
window.addEventListener("keydown", handler); window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);
}, [selectedImage, closeImage, goPrev, goNext, regionSelectMode, exitRegionMode, clampPan]); }, [
selectedImage,
closeImage,
exitSlideshow,
goPrev,
goNext,
goSlideshow,
regionSelectMode,
exitRegionMode,
clampPan,
showSlideshowControls,
slideshowActive,
]);
// ── Region selection / pan pointer handlers ───────────────────────────────── // ── Region selection / pan pointer handlers ─────────────────────────────────
const handleRegionPointerDown = useCallback( const handleRegionPointerDown = useCallback(
@@ -400,14 +661,159 @@ export function Lightbox() {
<AnimatePresence> <AnimatePresence>
{selectedImage ? ( {selectedImage ? (
<motion.div <motion.div
ref={lightboxRootRef}
key="lightbox" key="lightbox"
className="media-dark-surface fixed inset-0 z-50 flex bg-black/90 backdrop-blur-sm" className={`media-dark-surface fixed inset-0 z-50 flex ${
slideshowActive ? "bg-black" : "bg-black/90 backdrop-blur-sm"
}`}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.15 }} transition={{ duration: 0.15 }}
onClick={regionSelectMode ? undefined : closeImage} onClick={slideshowActive ? showSlideshowControls : regionSelectMode ? undefined : closeImage}
> >
{slideshowActive ? (
<div
className={`relative flex h-full w-full items-center justify-center overflow-hidden bg-black ${
slideshowControlsShown ? "" : "cursor-none"
}`}
onClick={(event) => {
event.stopPropagation();
showSlideshowControls();
}}
onPointerMove={handleSlideshowPointerMove}
>
<AnimatePresence initial={false}>
{selectedImage.media_kind === "image" ? (
<motion.div
key={selectedImage.id}
className="absolute inset-0 flex items-center justify-center"
initial={slideshowImageInitial}
animate={slideshowImageAnimate}
exit={slideshowImageExit}
transition={slideshowImageTransition}
>
<motion.img
src={mediaSrc(selectedImage.path) ?? ""}
alt={selectedImage.filename}
className="max-h-full max-w-full object-contain"
draggable={false}
initial={slideshowImageContentInitial}
animate={slideshowImageContentAnimate}
transition={slideshowImageContentTransition}
/>
</motion.div>
) : (
<motion.div
key="slideshow-waiting"
className="text-sm text-gray-500"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
Finding next image
</motion.div>
)}
</AnimatePresence>
<motion.div
className="pointer-events-none absolute inset-x-0 top-0 flex items-start justify-between p-5"
initial={false}
animate={{ opacity: slideshowControlsShown ? 1 : 0, y: slideshowControlsShown ? 0 : -6 }}
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
>
<div className="max-w-[60vw] whitespace-nowrap rounded-full border border-white/10 bg-black/45 px-3 py-1.5 text-xs text-gray-300 shadow-2xl shadow-black/30 backdrop-blur-md">
<span className="font-medium text-white">{slideshowPosition}</span>
<span className="mx-1 text-gray-600">/</span>
<span>{slideshowImages.length}</span>
<span className="mx-2 text-gray-700"></span>
<span className="inline-block max-w-[42vw] truncate align-bottom text-gray-400">{selectedImage.filename}</span>
</div>
<Tooltip label="Exit slideshow" followCursor>
<button
aria-label="Exit slideshow"
className="pointer-events-auto rounded-full border border-white/10 bg-black/45 p-2 text-gray-300 shadow-2xl shadow-black/30 backdrop-blur-md transition-colors hover:bg-white/10 hover:text-white"
onClick={(event) => {
event.stopPropagation();
exitSlideshow();
}}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Tooltip>
</motion.div>
<motion.div
className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-center p-6"
initial={false}
animate={{ opacity: slideshowControlsShown ? 1 : 0, y: slideshowControlsShown ? 0 : 8 }}
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
>
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/50 p-1.5 shadow-2xl shadow-black/30 backdrop-blur-md">
<Tooltip label="Previous image" followCursor>
<button
aria-label="Previous image"
className="rounded-full p-2 text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
disabled={slideshowImages.length <= 1}
onClick={(event) => {
event.stopPropagation();
goSlideshow(-1);
}}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 19l-7-7 7-7" />
</svg>
</button>
</Tooltip>
<Tooltip label={slideshowPaused ? "Resume slideshow" : "Pause slideshow"} followCursor>
<button
aria-label={slideshowPaused ? "Resume slideshow" : "Pause slideshow"}
className="rounded-full border border-white/10 bg-white/10 p-2.5 text-white transition-colors hover:bg-white/15"
onClick={(event) => {
event.stopPropagation();
setSlideshowPaused((paused) => !paused);
showSlideshowControls();
}}
>
{slideshowPaused ? (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5.14v13.72a1 1 0 001.52.86l10.55-6.86a1 1 0 000-1.72L9.52 4.28A1 1 0 008 5.14z" />
</svg>
) : (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 5a1 1 0 00-1 1v12a1 1 0 001 1h2a1 1 0 001-1V6a1 1 0 00-1-1H7zM15 5a1 1 0 00-1 1v12a1 1 0 001 1h2a1 1 0 001-1V6a1 1 0 00-1-1h-2z" />
</svg>
)}
</button>
</Tooltip>
<Tooltip label="Next image" followCursor>
<button
aria-label="Next image"
className="rounded-full p-2 text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
disabled={slideshowImages.length <= 1}
onClick={(event) => {
event.stopPropagation();
goSlideshow(1);
}}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 5l7 7-7 7" />
</svg>
</button>
</Tooltip>
</div>
</motion.div>
{slideshowLoadingMore ? (
<div className="pointer-events-none absolute bottom-6 right-6 rounded-full border border-white/10 bg-black/45 px-3 py-1.5 text-xs text-gray-500 backdrop-blur-md">
Loading more
</div>
) : null}
</div>
) : (
<>
<button <button
className="absolute left-4 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20" className="absolute left-4 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20"
disabled={currentIndex <= 0 || regionSelectMode} disabled={currentIndex <= 0 || regionSelectMode}
@@ -494,29 +900,56 @@ export function Lightbox() {
...(regionSelectMode ? { opacity: 0.85 } : {}), ...(regionSelectMode ? { opacity: 0.85 } : {}),
}} }}
/> />
</>
)}
</motion.div>
</AnimatePresence>
{!regionSelectMode && ( {!regionSelectMode && (
<div className="pointer-events-none absolute right-6 top-6 opacity-0 transition-opacity group-hover:opacity-100"> <div className="pointer-events-none absolute right-6 top-6 opacity-75 transition-opacity group-hover:opacity-100">
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur"> <div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 p-1 shadow-2xl shadow-black/25 backdrop-blur">
<Tooltip label={canStartSlideshow ? "Start slideshow" : "No images available for slideshow"} followCursor>
<button <button
className="px-2 text-sm text-gray-300 hover:text-white" aria-label="Start slideshow"
className={`rounded-full p-2 transition-colors ${
canStartSlideshow
? "text-gray-300 hover:bg-white/10 hover:text-white"
: "cursor-not-allowed text-gray-600"
}`}
disabled={!canStartSlideshow}
onClick={(event) => {
event.stopPropagation();
startSlideshow();
}}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8 5.5v13l10-6.5-10-6.5z" />
</svg>
</button>
</Tooltip>
{selectedImage.media_kind === "image" ? (
<>
<div className="mx-0.5 h-5 w-px bg-white/10" />
<button
aria-label="Zoom out"
className="rounded-full px-2 py-1 text-sm text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setView((v) => clampPan(zoomViewAt(v, Math.max(MIN_ZOOM, v.zoom - 0.25), 0, 0)))} onClick={() => setView((v) => clampPan(zoomViewAt(v, Math.max(MIN_ZOOM, v.zoom - 0.25), 0, 0)))}
> >
- -
</button> </button>
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span> <span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
<button <button
className="px-2 text-sm text-gray-300 hover:text-white" aria-label="Zoom in"
className="rounded-full px-2 py-1 text-sm text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setView((v) => clampPan(zoomViewAt(v, Math.min(MAX_ZOOM, v.zoom + 0.25), 0, 0)))} onClick={() => setView((v) => clampPan(zoomViewAt(v, Math.min(MAX_ZOOM, v.zoom + 0.25), 0, 0)))}
> >
+ +
</button> </button>
</div>
</div>
)}
</> </>
) : null}
</div>
</div>
)} )}
</motion.div>
</AnimatePresence>
</div> </div>
<div className="lightbox-panel flex w-64 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 lg:w-72"> <div className="lightbox-panel flex w-64 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 lg:w-72">
@@ -984,6 +1417,8 @@ export function Lightbox() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
</button> </button>
</>
)}
</motion.div> </motion.div>
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
+56 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggerModel, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, SlideshowOrder, SlideshowTransition, TaggerAcceleration, TaggerModel, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store";
import { FfmpegStatusRow } from "./onboarding/StepWelcome"; import { FfmpegStatusRow } from "./onboarding/StepWelcome";
import { ThemedDropdown } from "./ThemedDropdown"; import { ThemedDropdown } from "./ThemedDropdown";
import { getChangelogForVersion } from "../changelog"; import { getChangelogForVersion } from "../changelog";
@@ -257,6 +257,12 @@ export function SettingsModal() {
const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay); const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay);
const lightboxAutoMute = useGalleryStore((state) => state.lightboxAutoMute); const lightboxAutoMute = useGalleryStore((state) => state.lightboxAutoMute);
const setLightboxAutoMute = useGalleryStore((state) => state.setLightboxAutoMute); const setLightboxAutoMute = useGalleryStore((state) => state.setLightboxAutoMute);
const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds);
const setSlideshowIntervalSeconds = useGalleryStore((state) => state.setSlideshowIntervalSeconds);
const slideshowOrder = useGalleryStore((state) => state.slideshowOrder);
const setSlideshowOrder = useGalleryStore((state) => state.setSlideshowOrder);
const slideshowTransition = useGalleryStore((state) => state.slideshowTransition);
const setSlideshowTransition = useGalleryStore((state) => state.setSlideshowTransition);
useEffect(() => { useEffect(() => {
if (!settingsOpen) return; if (!settingsOpen) return;
@@ -814,6 +820,55 @@ export function SettingsModal() {
</SettingsItem> </SettingsItem>
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="Slideshow">
<SettingsItem
label="Slide duration"
description="How long each image stays on screen before the slideshow advances."
>
<div className="flex items-center gap-2">
<input
type="range"
min={3}
max={60}
step={1}
value={slideshowIntervalSeconds}
aria-label="Slide duration"
className="w-32 accent-sky-500"
onChange={(event) => setSlideshowIntervalSeconds(Number(event.currentTarget.value))}
/>
<span className="min-w-10 text-right text-xs tabular-nums text-gray-400">{slideshowIntervalSeconds}s</span>
</div>
</SettingsItem>
<SettingsItem
label="Playback order"
description="Sequential follows the current lightbox order. Random picks another image from the same collection."
>
<ThemedDropdown
value={slideshowOrder}
onChange={(value) => setSlideshowOrder(value as SlideshowOrder)}
ariaLabel="Slideshow order"
options={[
{ value: "sequential", label: "Sequential" },
{ value: "random", label: "Random" },
]}
/>
</SettingsItem>
<SettingsItem
label="Transition"
description="Soft fade keeps images still. Gentle motion adds a slow, subtle drift while the next image settles in."
>
<ThemedDropdown
value={slideshowTransition}
onChange={(value) => setSlideshowTransition(value as SlideshowTransition)}
ariaLabel="Slideshow transition"
options={[
{ value: "soft-fade", label: "Soft fade" },
{ value: "gentle-motion", label: "Gentle motion" },
]}
/>
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Updates"> <SettingsGroup title="Updates">
<SettingsItem <SettingsItem
label={ label={
+5 -2
View File
@@ -339,9 +339,11 @@ export function Toolbar() {
</div> </div>
) : null} ) : null}
{searchQuery.trim().length > 0 || searchCommand !== null ? ( {searchQuery.trim().length > 0 || searchCommand !== null ? (
<Tooltip label="Clear search" anchorToCursor className="absolute right-2 top-1/2 -translate-y-1/2"> <div className="absolute right-2 top-1/2 -translate-y-1/2">
<Tooltip label="Clear search" anchorToCursor>
<button <button
className="rounded-md p-1 text-gray-500 hover:bg-white/5 hover:text-gray-200 transition-colors" aria-label="Clear search"
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-white/5 hover:text-gray-200"
onClick={() => { onClick={() => {
setSearchCommand(null); setSearchCommand(null);
setSearchQuery(""); setSearchQuery("");
@@ -354,6 +356,7 @@ export function Toolbar() {
</svg> </svg>
</button> </button>
</Tooltip> </Tooltip>
</div>
) : null} ) : null}
</div> </div>
</div> </div>
+51
View File
@@ -15,6 +15,9 @@ const NOTIFICATION_DEBOUNCE_MS = 6000;
const THEME_KEY = "phokus-theme"; const THEME_KEY = "phokus-theme";
const LIGHTBOX_AUTOPLAY_KEY = "phokus.lightboxAutoplay"; const LIGHTBOX_AUTOPLAY_KEY = "phokus.lightboxAutoplay";
const LIGHTBOX_AUTO_MUTE_KEY = "phokus.lightboxAutoMute"; const LIGHTBOX_AUTO_MUTE_KEY = "phokus.lightboxAutoMute";
const SLIDESHOW_INTERVAL_KEY = "phokus.slideshowIntervalSeconds";
const SLIDESHOW_ORDER_KEY = "phokus.slideshowOrder";
const SLIDESHOW_TRANSITION_KEY = "phokus.slideshowTransition";
export interface Folder { export interface Folder {
id: number; id: number;
@@ -57,6 +60,8 @@ export type TaggingQueueScope = "all" | "selected";
export type SimilarScope = "all_media" | "current_folder" | "current_album"; export type SimilarScope = "all_media" | "current_folder" | "current_album";
export type ExploreMode = "visual" | "tags"; export type ExploreMode = "visual" | "tags";
export type AppTheme = "phokus" | "subtle-light" | "conventional-dark"; export type AppTheme = "phokus" | "subtle-light" | "conventional-dark";
export type SlideshowOrder = "sequential" | "random";
export type SlideshowTransition = "soft-fade" | "gentle-motion";
export interface ImageRecord { export interface ImageRecord {
id: number; id: number;
@@ -414,6 +419,9 @@ interface GalleryState {
theme: AppTheme; theme: AppTheme;
lightboxAutoplay: boolean; lightboxAutoplay: boolean;
lightboxAutoMute: boolean; lightboxAutoMute: boolean;
slideshowIntervalSeconds: number;
slideshowOrder: SlideshowOrder;
slideshowTransition: SlideshowTransition;
// Per-folder background-worker pause flags, shared by the BackgroundTasks // Per-folder background-worker pause flags, shared by the BackgroundTasks
// bar and the sidebar folder context menu. // bar and the sidebar folder context menu.
workerPaused: Record<number, Record<WorkerKey, boolean>>; workerPaused: Record<number, Record<WorkerKey, boolean>>;
@@ -542,6 +550,9 @@ interface GalleryState {
setTheme: (theme: AppTheme) => void; setTheme: (theme: AppTheme) => void;
setLightboxAutoplay: (enabled: boolean) => void; setLightboxAutoplay: (enabled: boolean) => void;
setLightboxAutoMute: (enabled: boolean) => void; setLightboxAutoMute: (enabled: boolean) => void;
setSlideshowIntervalSeconds: (seconds: number) => void;
setSlideshowOrder: (order: SlideshowOrder) => void;
setSlideshowTransition: (transition: SlideshowTransition) => void;
loadWorkerStates: () => Promise<void>; loadWorkerStates: () => Promise<void>;
setWorkerPaused: (folderId: number, worker: WorkerKey, paused: boolean) => void; setWorkerPaused: (folderId: number, worker: WorkerKey, paused: boolean) => void;
setAllWorkersPaused: (folderId: number, paused: boolean) => void; setAllWorkersPaused: (folderId: number, paused: boolean) => void;
@@ -652,6 +663,27 @@ function initialBoolSetting(key: string, fallback: boolean): boolean {
return stored === null ? fallback : stored === "true"; return stored === null ? fallback : stored === "true";
} }
function initialNumberSetting(key: string, fallback: number, min: number, max: number): number {
if (typeof window === "undefined") return fallback;
const raw = window.localStorage.getItem(key);
if (raw === null) return fallback;
const stored = Number(raw);
if (!Number.isFinite(stored)) return fallback;
return Math.min(max, Math.max(min, stored));
}
function initialSlideshowOrder(): SlideshowOrder {
if (typeof window === "undefined") return "sequential";
const stored = window.localStorage.getItem(SLIDESHOW_ORDER_KEY);
return stored === "random" ? "random" : "sequential";
}
function initialSlideshowTransition(): SlideshowTransition {
if (typeof window === "undefined") return "soft-fade";
const stored = window.localStorage.getItem(SLIDESHOW_TRANSITION_KEY);
return stored === "gentle-motion" ? "gentle-motion" : "soft-fade";
}
function initialTheme(): AppTheme { function initialTheme(): AppTheme {
if (typeof window === "undefined") return "phokus"; if (typeof window === "undefined") return "phokus";
const saved = window.localStorage.getItem(THEME_KEY); const saved = window.localStorage.getItem(THEME_KEY);
@@ -912,6 +944,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
theme: initialTheme(), theme: initialTheme(),
lightboxAutoplay: initialBoolSetting(LIGHTBOX_AUTOPLAY_KEY, true), lightboxAutoplay: initialBoolSetting(LIGHTBOX_AUTOPLAY_KEY, true),
lightboxAutoMute: initialBoolSetting(LIGHTBOX_AUTO_MUTE_KEY, false), lightboxAutoMute: initialBoolSetting(LIGHTBOX_AUTO_MUTE_KEY, false),
slideshowIntervalSeconds: initialNumberSetting(SLIDESHOW_INTERVAL_KEY, 6, 3, 60),
slideshowOrder: initialSlideshowOrder(),
slideshowTransition: initialSlideshowTransition(),
workerPaused: {}, workerPaused: {},
appVersion: null, appVersion: null,
@@ -2002,6 +2037,22 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
set({ lightboxAutoMute: enabled }); set({ lightboxAutoMute: enabled });
}, },
setSlideshowIntervalSeconds: (seconds) => {
const next = Math.min(60, Math.max(3, Math.round(seconds)));
window.localStorage.setItem(SLIDESHOW_INTERVAL_KEY, String(next));
set({ slideshowIntervalSeconds: next });
},
setSlideshowOrder: (order) => {
window.localStorage.setItem(SLIDESHOW_ORDER_KEY, order);
set({ slideshowOrder: order });
},
setSlideshowTransition: (transition) => {
window.localStorage.setItem(SLIDESHOW_TRANSITION_KEY, transition);
set({ slideshowTransition: transition });
},
loadWorkerStates: async () => { loadWorkerStates: async () => {
const folderIds = get().folders.map((folder) => folder.id); const folderIds = get().folders.map((folder) => folder.id);
if (folderIds.length === 0) { if (folderIds.length === 0) {