From 31b46327fd6e6a29ae95f6d5932736dcf26f7ad2 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Wed, 1 Jul 2026 22:23:32 +0100 Subject: [PATCH] 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) {