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.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useCallback, useMemo, 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,7 @@ 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_CONTROLS_IDLE_MS = 5000;
|
||||||
const SLIDESHOW_LOAD_MORE_THRESHOLD = 3;
|
const SLIDESHOW_LOAD_MORE_THRESHOLD = 3;
|
||||||
|
|
||||||
@@ -165,6 +166,7 @@ export function Lightbox() {
|
|||||||
const totalImages = useGalleryStore((state) => state.totalImages);
|
const totalImages = useGalleryStore((state) => state.totalImages);
|
||||||
const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds);
|
const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds);
|
||||||
const slideshowOrder = useGalleryStore((state) => state.slideshowOrder);
|
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.
|
||||||
@@ -195,12 +197,14 @@ export function Lightbox() {
|
|||||||
const [slideshowPaused, setSlideshowPaused] = useState(false);
|
const [slideshowPaused, setSlideshowPaused] = useState(false);
|
||||||
const [slideshowControlsVisible, setSlideshowControlsVisible] = useState(true);
|
const [slideshowControlsVisible, setSlideshowControlsVisible] = useState(true);
|
||||||
const [slideshowLoadingMore, setSlideshowLoadingMore] = useState(false);
|
const [slideshowLoadingMore, setSlideshowLoadingMore] = useState(false);
|
||||||
|
const [slideshowMotionStep, setSlideshowMotionStep] = useState(0);
|
||||||
|
|
||||||
const lightboxRootRef = useRef<HTMLDivElement>(null);
|
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 slideshowControlsIdleTimeoutRef = useRef<number | null>(null);
|
||||||
const slideshowPointerRef = useRef<{ x: number; y: 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(
|
const slideshowImages = useMemo(
|
||||||
@@ -212,6 +216,41 @@ export function Lightbox() {
|
|||||||
: -1;
|
: -1;
|
||||||
const slideshowPosition = slideshowIndex >= 0 ? slideshowIndex + 1 : Math.min(slideshowImages.length, 1);
|
const slideshowPosition = slideshowIndex >= 0 ? slideshowIndex + 1 : Math.min(slideshowImages.length, 1);
|
||||||
const slideshowControlsShown = slideshowControlsVisible;
|
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 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";
|
||||||
@@ -265,9 +304,19 @@ export function Lightbox() {
|
|||||||
let nextIndex =
|
let nextIndex =
|
||||||
(currentSlideshowIndex + direction + slideshowImages.length) % slideshowImages.length;
|
(currentSlideshowIndex + direction + slideshowImages.length) % slideshowImages.length;
|
||||||
if (slideshowOrder === "random") {
|
if (slideshowOrder === "random") {
|
||||||
nextIndex = Math.floor(Math.random() * (slideshowImages.length - 1));
|
const currentId = slideshowImages[currentSlideshowIndex]?.id;
|
||||||
if (nextIndex >= currentSlideshowIndex) nextIndex += 1;
|
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]);
|
openImage(slideshowImages[nextIndex]);
|
||||||
if (revealControls) {
|
if (revealControls) {
|
||||||
showSlideshowControls();
|
showSlideshowControls();
|
||||||
@@ -309,6 +358,8 @@ export function Lightbox() {
|
|||||||
openImage(nextImage);
|
openImage(nextImage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
slideshowRandomSeenRef.current = new Set([nextImage.id]);
|
||||||
|
setSlideshowMotionStep(0);
|
||||||
exitRegionMode();
|
exitRegionMode();
|
||||||
setView(IDENTITY_VIEW);
|
setView(IDENTITY_VIEW);
|
||||||
setSlideshowPaused(false);
|
setSlideshowPaused(false);
|
||||||
@@ -632,19 +683,26 @@ export function Lightbox() {
|
|||||||
}}
|
}}
|
||||||
onPointerMove={handleSlideshowPointerMove}
|
onPointerMove={handleSlideshowPointerMove}
|
||||||
>
|
>
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence initial={false}>
|
||||||
{selectedImage.media_kind === "image" ? (
|
{selectedImage.media_kind === "image" ? (
|
||||||
<motion.img
|
<motion.div
|
||||||
key={selectedImage.id}
|
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) ?? ""}
|
src={mediaSrc(selectedImage.path) ?? ""}
|
||||||
alt={selectedImage.filename}
|
alt={selectedImage.filename}
|
||||||
className="max-h-full max-w-full object-contain"
|
className="max-h-full max-w-full object-contain"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
initial={{ opacity: 0, scale: 1.012, filter: "blur(8px)" }}
|
initial={slideshowImageContentInitial}
|
||||||
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
animate={slideshowImageContentAnimate}
|
||||||
exit={{ opacity: 0, scale: 0.992, filter: "blur(4px)" }}
|
transition={slideshowImageContentTransition}
|
||||||
transition={{ duration: 0.72, ease: [0.22, 1, 0.36, 1] }}
|
|
||||||
/>
|
/>
|
||||||
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="slideshow-waiting"
|
key="slideshow-waiting"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, SlideshowOrder, 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";
|
||||||
@@ -261,6 +261,8 @@ export function SettingsModal() {
|
|||||||
const setSlideshowIntervalSeconds = useGalleryStore((state) => state.setSlideshowIntervalSeconds);
|
const setSlideshowIntervalSeconds = useGalleryStore((state) => state.setSlideshowIntervalSeconds);
|
||||||
const slideshowOrder = useGalleryStore((state) => state.slideshowOrder);
|
const slideshowOrder = useGalleryStore((state) => state.slideshowOrder);
|
||||||
const setSlideshowOrder = useGalleryStore((state) => state.setSlideshowOrder);
|
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;
|
||||||
@@ -851,6 +853,20 @@ export function SettingsModal() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</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>
|
||||||
|
|
||||||
<SettingsGroup title="Updates">
|
<SettingsGroup title="Updates">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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_INTERVAL_KEY = "phokus.slideshowIntervalSeconds";
|
||||||
const SLIDESHOW_ORDER_KEY = "phokus.slideshowOrder";
|
const SLIDESHOW_ORDER_KEY = "phokus.slideshowOrder";
|
||||||
|
const SLIDESHOW_TRANSITION_KEY = "phokus.slideshowTransition";
|
||||||
|
|
||||||
export interface Folder {
|
export interface Folder {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -60,6 +61,7 @@ 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 SlideshowOrder = "sequential" | "random";
|
||||||
|
export type SlideshowTransition = "soft-fade" | "gentle-motion";
|
||||||
|
|
||||||
export interface ImageRecord {
|
export interface ImageRecord {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -419,6 +421,7 @@ interface GalleryState {
|
|||||||
lightboxAutoMute: boolean;
|
lightboxAutoMute: boolean;
|
||||||
slideshowIntervalSeconds: number;
|
slideshowIntervalSeconds: number;
|
||||||
slideshowOrder: SlideshowOrder;
|
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>>;
|
||||||
@@ -549,6 +552,7 @@ interface GalleryState {
|
|||||||
setLightboxAutoMute: (enabled: boolean) => void;
|
setLightboxAutoMute: (enabled: boolean) => void;
|
||||||
setSlideshowIntervalSeconds: (seconds: number) => void;
|
setSlideshowIntervalSeconds: (seconds: number) => void;
|
||||||
setSlideshowOrder: (order: SlideshowOrder) => 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;
|
||||||
@@ -674,6 +678,12 @@ function initialSlideshowOrder(): SlideshowOrder {
|
|||||||
return stored === "random" ? "random" : "sequential";
|
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);
|
||||||
@@ -936,6 +946,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
lightboxAutoMute: initialBoolSetting(LIGHTBOX_AUTO_MUTE_KEY, false),
|
lightboxAutoMute: initialBoolSetting(LIGHTBOX_AUTO_MUTE_KEY, false),
|
||||||
slideshowIntervalSeconds: initialNumberSetting(SLIDESHOW_INTERVAL_KEY, 6, 3, 60),
|
slideshowIntervalSeconds: initialNumberSetting(SLIDESHOW_INTERVAL_KEY, 6, 3, 60),
|
||||||
slideshowOrder: initialSlideshowOrder(),
|
slideshowOrder: initialSlideshowOrder(),
|
||||||
|
slideshowTransition: initialSlideshowTransition(),
|
||||||
workerPaused: {},
|
workerPaused: {},
|
||||||
|
|
||||||
appVersion: null,
|
appVersion: null,
|
||||||
@@ -2037,6 +2048,11 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
set({ slideshowOrder: 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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user