From 31b46327fd6e6a29ae95f6d5932736dcf26f7ad2 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Wed, 1 Jul 2026 22:23:32 +0100 Subject: [PATCH 1/2] 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. --- CHANGELOG.md | 4 + src-tauri/src/commands.rs | 4 +- src/components/Lightbox.tsx | 427 +++++++++++++++++++++++++++++-- src/components/SettingsModal.tsx | 41 ++- src/components/Toolbar.tsx | 33 +-- src/store.ts | 35 +++ 6 files changed, 501 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc61fe4..83828d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9a80c58..a10f80b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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) } diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index 3665a31..f9b7cf3 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback, useRef, useState } from "react"; +import { useEffect, useCallback, useMemo, useRef, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { invoke } from "@tauri-apps/api/core"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; @@ -128,6 +128,8 @@ interface ViewTransform { } const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 }; +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 +160,11 @@ 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); // 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 +191,28 @@ export function Lightbox() { const [isDragging, setIsDragging] = useState(false); const [dragRect, setDragRect] = useState(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 lightboxRootRef = useRef(null); const imageViewportRef = useRef(null); const imgRef = useRef(null); + const slideshowControlsIdleTimeoutRef = useRef(null); + const slideshowPointerRef = useRef<{ x: number; y: number } | null>(null); 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 canStartSlideshow = slideshowImages.length > 0; const canFindSimilar = selectedImage?.embedding_status === "ready"; const canSearchRegion = canFindSimilar && selectedImage?.media_kind === "image"; @@ -206,6 +230,94 @@ 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") { + nextIndex = Math.floor(Math.random() * (slideshowImages.length - 1)); + if (nextIndex >= currentSlideshowIndex) nextIndex += 1; + } + openImage(slideshowImages[nextIndex]); + if (revealControls) { + showSlideshowControls(); + } + }, + [openImage, showSlideshowControls, slideshowImages, slideshowIndex, slideshowOrder], + ); + + const handleSlideshowPointerMove = useCallback( + (event: React.PointerEvent) => { + 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); + } + + 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 +375,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 +457,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 +505,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 +610,152 @@ export function Lightbox() { {selectedImage ? ( + {slideshowActive ? ( +
{ + event.stopPropagation(); + showSlideshowControls(); + }} + onPointerMove={handleSlideshowPointerMove} + > + + {selectedImage.media_kind === "image" ? ( + + ) : ( + + Finding next image… + + )} + + + +
+ {slideshowPosition} + / + {slideshowImages.length} + + {selectedImage.filename} +
+ + + +
+ + +
+ + + + + + + + + +
+
+ + {slideshowLoadingMore ? ( +
+ Loading more… +
+ ) : null} +
+ ) : ( + <> - {Math.round(zoom * 100)}% - - - - )} )}
+ + {!regionSelectMode && ( +
+
+ + + + {selectedImage.media_kind === "image" ? ( + <> +
+ + {Math.round(zoom * 100)}% + + + ) : null} +
+
+ )}
@@ -984,6 +1359,8 @@ export function Lightbox() { + + )} ) : null} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index b7556cc..08340b5 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -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, TaggerAcceleration, TaggerModel, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; import { FfmpegStatusRow } from "./onboarding/StepWelcome"; import { ThemedDropdown } from "./ThemedDropdown"; import { getChangelogForVersion } from "../changelog"; @@ -257,6 +257,10 @@ 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); useEffect(() => { if (!settingsOpen) return; @@ -814,6 +818,41 @@ export function SettingsModal() { + + +
+ setSlideshowIntervalSeconds(Number(event.currentTarget.value))} + /> + {slideshowIntervalSeconds}s +
+
+ + setSlideshowOrder(value as SlideshowOrder)} + ariaLabel="Slideshow order" + options={[ + { value: "sequential", label: "Sequential" }, + { value: "random", label: "Random" }, + ]} + /> + +
+ ) : null} {searchQuery.trim().length > 0 || searchCommand !== null ? ( - - - +
+ + + +
) : null}
diff --git a/src/store.ts b/src/store.ts index 36ae2da..e572f67 100644 --- a/src/store.ts +++ b/src/store.ts @@ -15,6 +15,8 @@ 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"; export interface Folder { id: number; @@ -57,6 +59,7 @@ 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 interface ImageRecord { id: number; @@ -414,6 +417,8 @@ interface GalleryState { theme: AppTheme; lightboxAutoplay: boolean; lightboxAutoMute: boolean; + slideshowIntervalSeconds: number; + slideshowOrder: SlideshowOrder; // Per-folder background-worker pause flags, shared by the BackgroundTasks // bar and the sidebar folder context menu. workerPaused: Record>; @@ -542,6 +547,8 @@ interface GalleryState { setTheme: (theme: AppTheme) => void; setLightboxAutoplay: (enabled: boolean) => void; setLightboxAutoMute: (enabled: boolean) => void; + setSlideshowIntervalSeconds: (seconds: number) => void; + setSlideshowOrder: (order: SlideshowOrder) => void; loadWorkerStates: () => Promise; setWorkerPaused: (folderId: number, worker: WorkerKey, paused: boolean) => void; setAllWorkersPaused: (folderId: number, paused: boolean) => void; @@ -652,6 +659,21 @@ 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 initialTheme(): AppTheme { if (typeof window === "undefined") return "phokus"; const saved = window.localStorage.getItem(THEME_KEY); @@ -912,6 +934,8 @@ export const useGalleryStore = create((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(), workerPaused: {}, appVersion: null, @@ -2002,6 +2026,17 @@ export const useGalleryStore = create((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 }); + }, + loadWorkerStates: async () => { const folderIds = get().folders.map((folder) => folder.id); if (folderIds.length === 0) { From 68d19d219eabf782daaeba14d42fa4415c61609c Mon Sep 17 00:00:00 2001 From: LyAhn Date: Thu, 2 Jul 2026 11:39:19 +0100 Subject: [PATCH 2/2] 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. --- src/components/Lightbox.tsx | 86 ++++++++++++++++++++++++++------ src/components/SettingsModal.tsx | 18 ++++++- src/store.ts | 16 ++++++ 3 files changed, 105 insertions(+), 15 deletions(-) diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index f9b7cf3..bedb93f 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -1,5 +1,5 @@ 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 { revealItemInDir } from "@tauri-apps/plugin-opener"; import { useGalleryStore, ImageTag, ImageExif, AiRating } from "../store"; @@ -128,6 +128,7 @@ 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; @@ -165,6 +166,7 @@ export function Lightbox() { 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. @@ -195,12 +197,14 @@ export function Lightbox() { const [slideshowPaused, setSlideshowPaused] = useState(false); const [slideshowControlsVisible, setSlideshowControlsVisible] = useState(true); const [slideshowLoadingMore, setSlideshowLoadingMore] = useState(false); + const [slideshowMotionStep, setSlideshowMotionStep] = useState(0); const lightboxRootRef = useRef(null); const imageViewportRef = useRef(null); const imgRef = useRef(null); const slideshowControlsIdleTimeoutRef = useRef(null); const slideshowPointerRef = useRef<{ x: number; y: number } | null>(null); + const slideshowRandomSeenRef = useRef>(new Set()); const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1; const slideshowImages = useMemo( @@ -212,6 +216,41 @@ export function Lightbox() { : -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"; @@ -265,9 +304,19 @@ export function Lightbox() { let nextIndex = (currentSlideshowIndex + direction + slideshowImages.length) % slideshowImages.length; if (slideshowOrder === "random") { - nextIndex = Math.floor(Math.random() * (slideshowImages.length - 1)); - if (nextIndex >= currentSlideshowIndex) nextIndex += 1; + 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(); @@ -309,6 +358,8 @@ export function Lightbox() { openImage(nextImage); } + slideshowRandomSeenRef.current = new Set([nextImage.id]); + setSlideshowMotionStep(0); exitRegionMode(); setView(IDENTITY_VIEW); setSlideshowPaused(false); @@ -632,19 +683,26 @@ export function Lightbox() { }} onPointerMove={handleSlideshowPointerMove} > - + {selectedImage.media_kind === "image" ? ( - + className="absolute inset-0 flex items-center justify-center" + initial={slideshowImageInitial} + animate={slideshowImageAnimate} + exit={slideshowImageExit} + transition={slideshowImageTransition} + > + + ) : ( 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; @@ -851,6 +853,20 @@ export function SettingsModal() { ]} /> + + setSlideshowTransition(value as SlideshowTransition)} + ariaLabel="Slideshow transition" + options={[ + { value: "soft-fade", label: "Soft fade" }, + { value: "gentle-motion", label: "Gentle motion" }, + ]} + /> + diff --git a/src/store.ts b/src/store.ts index e572f67..ea3b32e 100644 --- a/src/store.ts +++ b/src/store.ts @@ -17,6 +17,7 @@ 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; @@ -60,6 +61,7 @@ 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; @@ -419,6 +421,7 @@ interface GalleryState { 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>; @@ -549,6 +552,7 @@ interface GalleryState { setLightboxAutoMute: (enabled: boolean) => void; setSlideshowIntervalSeconds: (seconds: number) => void; setSlideshowOrder: (order: SlideshowOrder) => void; + setSlideshowTransition: (transition: SlideshowTransition) => void; loadWorkerStates: () => Promise; setWorkerPaused: (folderId: number, worker: WorkerKey, paused: boolean) => void; setAllWorkersPaused: (folderId: number, paused: boolean) => void; @@ -674,6 +678,12 @@ function initialSlideshowOrder(): SlideshowOrder { 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); @@ -936,6 +946,7 @@ export const useGalleryStore = create((set, get) => ({ lightboxAutoMute: initialBoolSetting(LIGHTBOX_AUTO_MUTE_KEY, false), slideshowIntervalSeconds: initialNumberSetting(SLIDESHOW_INTERVAL_KEY, 6, 3, 60), slideshowOrder: initialSlideshowOrder(), + slideshowTransition: initialSlideshowTransition(), workerPaused: {}, appVersion: null, @@ -2037,6 +2048,11 @@ export const useGalleryStore = create((set, get) => ({ 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) {