Compare commits

...

3 Commits

Author SHA1 Message Date
LyAhn 749b23723a 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.
2026-07-02 17:52:30 +01:00
LyAhn 68d19d219e feat(lightbox): add gentle slideshow motion
Add a selectable slideshow transition setting with a gentle motion option, keep the default soft fade, and make random slideshow navigation avoid repeats until the current image pool is exhausted.

Split the slideshow crossfade layer from the slow image drift so transitions overlap cleanly without black frames or lingering stale slide layers.
2026-07-02 11:39:19 +01:00
LyAhn 31b46327fd feat(lightbox): add slideshow mode
Adds a fullscreen image-only slideshow from the current lightbox collection, with pause, keyboard navigation, hidden idle controls, and polished image transitions.

Adds slideshow duration and playback order settings, including random order support.
2026-07-02 08:19:13 +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.
- **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.
- **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
+2 -2
View File
@@ -2400,8 +2400,8 @@ pub async fn reset_ai_tags(
(None, false) => {
let mut total = 0usize;
for &folder_id in &requested_folder_ids {
total += db::reset_ai_tags(&conn, Some(folder_id))
.map_err(|e| e.to_string())?;
total +=
db::reset_ai_tags(&conn, Some(folder_id)).map_err(|e| e.to_string())?;
}
(total, requested_folder_ids)
}
+451 -16
View File
@@ -1,5 +1,5 @@
import { useEffect, useCallback, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useEffect, useCallback, useMemo, useRef, useState } from "react";
import { motion, AnimatePresence, type Transition } from "framer-motion";
import { invoke } from "@tauri-apps/api/core";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { useGalleryStore, ImageTag, ImageExif, AiRating } from "../store";
@@ -128,6 +128,9 @@ interface ViewTransform {
}
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
* viewport centre stays under the cursor across a zoom change. */
@@ -158,6 +161,12 @@ export function Lightbox() {
const addToAlbum = useGalleryStore((state) => state.addToAlbum);
const createAlbum = useGalleryStore((state) => state.createAlbum);
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
// 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 [dragRect, setDragRect] = useState<DragRect | null>(null);
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 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 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 canSearchRegion = canFindSimilar && selectedImage?.media_kind === "image";
@@ -206,6 +269,106 @@ export function Lightbox() {
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
// the image edge may not be dragged past the viewport edge; when it fits,
// the pan snaps back to centre on that axis.
@@ -263,9 +426,71 @@ export function Lightbox() {
if (selectedImage?.ai_tagged_at) setTaggingQueued(false);
}, [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(() => {
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) => {
if (regionSelectMode) return; // don't zoom during selection
@@ -283,11 +508,35 @@ export function Lightbox() {
viewport.addEventListener("wheel", handleWheel, { passive: false });
return () => viewport.removeEventListener("wheel", handleWheel);
}, [selectedImage, regionSelectMode, clampPan]);
}, [selectedImage, regionSelectMode, clampPan, slideshowActive]);
useEffect(() => {
const handler = (event: KeyboardEvent) => {
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 (regionSelectMode) {
exitRegionMode();
@@ -307,7 +556,19 @@ export function Lightbox() {
window.addEventListener("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 ─────────────────────────────────
const handleRegionPointerDown = useCallback(
@@ -400,14 +661,159 @@ export function Lightbox() {
<AnimatePresence>
{selectedImage ? (
<motion.div
ref={lightboxRootRef}
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 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
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
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}
@@ -494,29 +900,56 @@ export function Lightbox() {
...(regionSelectMode ? { opacity: 0.85 } : {}),
}}
/>
</>
)}
</motion.div>
</AnimatePresence>
{!regionSelectMode && (
<div className="pointer-events-none absolute right-6 top-6 opacity-0 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-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 p-1 shadow-2xl shadow-black/25 backdrop-blur">
<Tooltip label={canStartSlideshow ? "Start slideshow" : "No images available for slideshow"} followCursor>
<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)))}
>
-
</button>
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
<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)))}
>
+
</button>
</div>
</div>
)}
</>
) : null}
</div>
</div>
)}
</motion.div>
</AnimatePresence>
</div>
<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" />
</svg>
</button>
</>
)}
</motion.div>
) : null}
</AnimatePresence>
+56 -1
View File
@@ -1,5 +1,5 @@
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 { ThemedDropdown } from "./ThemedDropdown";
import { getChangelogForVersion } from "../changelog";
@@ -257,6 +257,12 @@ export function SettingsModal() {
const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay);
const lightboxAutoMute = useGalleryStore((state) => state.lightboxAutoMute);
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(() => {
if (!settingsOpen) return;
@@ -814,6 +820,55 @@ export function SettingsModal() {
</SettingsItem>
</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">
<SettingsItem
label={
+5 -2
View File
@@ -339,9 +339,11 @@ export function Toolbar() {
</div>
) : 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
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={() => {
setSearchCommand(null);
setSearchQuery("");
@@ -354,6 +356,7 @@ export function Toolbar() {
</svg>
</button>
</Tooltip>
</div>
) : null}
</div>
</div>
+51
View File
@@ -15,6 +15,9 @@ const NOTIFICATION_DEBOUNCE_MS = 6000;
const THEME_KEY = "phokus-theme";
const LIGHTBOX_AUTOPLAY_KEY = "phokus.lightboxAutoplay";
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 {
id: number;
@@ -57,6 +60,8 @@ export type TaggingQueueScope = "all" | "selected";
export type SimilarScope = "all_media" | "current_folder" | "current_album";
export type ExploreMode = "visual" | "tags";
export type AppTheme = "phokus" | "subtle-light" | "conventional-dark";
export type SlideshowOrder = "sequential" | "random";
export type SlideshowTransition = "soft-fade" | "gentle-motion";
export interface ImageRecord {
id: number;
@@ -414,6 +419,9 @@ interface GalleryState {
theme: AppTheme;
lightboxAutoplay: boolean;
lightboxAutoMute: boolean;
slideshowIntervalSeconds: number;
slideshowOrder: SlideshowOrder;
slideshowTransition: SlideshowTransition;
// Per-folder background-worker pause flags, shared by the BackgroundTasks
// bar and the sidebar folder context menu.
workerPaused: Record<number, Record<WorkerKey, boolean>>;
@@ -542,6 +550,9 @@ interface GalleryState {
setTheme: (theme: AppTheme) => void;
setLightboxAutoplay: (enabled: boolean) => void;
setLightboxAutoMute: (enabled: boolean) => void;
setSlideshowIntervalSeconds: (seconds: number) => void;
setSlideshowOrder: (order: SlideshowOrder) => void;
setSlideshowTransition: (transition: SlideshowTransition) => void;
loadWorkerStates: () => Promise<void>;
setWorkerPaused: (folderId: number, worker: WorkerKey, 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";
}
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 {
if (typeof window === "undefined") return "phokus";
const saved = window.localStorage.getItem(THEME_KEY);
@@ -912,6 +944,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
theme: initialTheme(),
lightboxAutoplay: initialBoolSetting(LIGHTBOX_AUTOPLAY_KEY, true),
lightboxAutoMute: initialBoolSetting(LIGHTBOX_AUTO_MUTE_KEY, false),
slideshowIntervalSeconds: initialNumberSetting(SLIDESHOW_INTERVAL_KEY, 6, 3, 60),
slideshowOrder: initialSlideshowOrder(),
slideshowTransition: initialSlideshowTransition(),
workerPaused: {},
appVersion: null,
@@ -2002,6 +2037,22 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
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 () => {
const folderIds = get().folders.map((folder) => folder.id);
if (folderIds.length === 0) {