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) {