mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
[Feature] Added slideshow to gallery in wall display mode (#1224)
This commit is contained in:
@@ -1,15 +1,30 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { Button } from "react-bootstrap";
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
FormControl,
|
||||
InputGroup,
|
||||
FormLabel,
|
||||
OverlayTrigger,
|
||||
Popover,
|
||||
} from "react-bootstrap";
|
||||
import cx from "classnames";
|
||||
import Mousetrap from "mousetrap";
|
||||
import { debounce } from "lodash";
|
||||
import debounce from "lodash/debounce";
|
||||
|
||||
import { Icon, LoadingIndicator } from "src/components/Shared";
|
||||
import { useInterval, usePageVisibility } from "src/hooks";
|
||||
import { useConfiguration } from "src/core/StashService";
|
||||
|
||||
const CLASSNAME = "Lightbox";
|
||||
const CLASSNAME_HEADER = `${CLASSNAME}-header`;
|
||||
const CLASSNAME_LEFT_SPACER = `${CLASSNAME_HEADER}-left-spacer`;
|
||||
const CLASSNAME_INDICATOR = `${CLASSNAME_HEADER}-indicator`;
|
||||
const CLASSNAME_DELAY = `${CLASSNAME_HEADER}-delay`;
|
||||
const CLASSNAME_DELAY_ICON = `${CLASSNAME_DELAY}-icon`;
|
||||
const CLASSNAME_DELAY_INLINE = `${CLASSNAME_DELAY}-inline`;
|
||||
const CLASSNAME_RIGHT = `${CLASSNAME_HEADER}-right`;
|
||||
const CLASSNAME_DISPLAY = `${CLASSNAME}-display`;
|
||||
const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`;
|
||||
const CLASSNAME_INSTANT = `${CLASSNAME_CAROUSEL}-instant`;
|
||||
@@ -19,6 +34,10 @@ const CLASSNAME_NAV = `${CLASSNAME}-nav`;
|
||||
const CLASSNAME_NAVIMAGE = `${CLASSNAME_NAV}-image`;
|
||||
const CLASSNAME_NAVSELECTED = `${CLASSNAME_NAV}-selected`;
|
||||
|
||||
const DEFAULT_SLIDESHOW_DELAY = 5000;
|
||||
const SECONDS_TO_MS = 1000;
|
||||
const MIN_VALID_INTERVAL_SECONDS = 1;
|
||||
|
||||
type Image = Pick<GQL.Image, "paths">;
|
||||
interface IProps {
|
||||
images: Image[];
|
||||
@@ -26,6 +45,7 @@ interface IProps {
|
||||
isLoading: boolean;
|
||||
initialIndex?: number;
|
||||
showNavigation: boolean;
|
||||
slideshowEnabled?: boolean;
|
||||
pageHeader?: string;
|
||||
pageCallback?: (direction: number) => boolean;
|
||||
hide: () => void;
|
||||
@@ -37,6 +57,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
isLoading,
|
||||
initialIndex = 0,
|
||||
showNavigation,
|
||||
slideshowEnabled = false,
|
||||
pageHeader,
|
||||
pageCallback,
|
||||
hide,
|
||||
@@ -49,6 +70,27 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
const carouselRef = useRef<HTMLDivElement | null>(null);
|
||||
const indicatorRef = useRef<HTMLDivElement | null>(null);
|
||||
const navRef = useRef<HTMLDivElement | null>(null);
|
||||
const clearIntervalCallback = useRef<() => void>();
|
||||
const resetIntervalCallback = useRef<() => void>();
|
||||
const config = useConfiguration();
|
||||
|
||||
const userSelectedSlideshowDelayOrDefault =
|
||||
config?.data?.configuration.interface.slideshowDelay ??
|
||||
DEFAULT_SLIDESHOW_DELAY;
|
||||
|
||||
// slideshowInterval is used for controlling the logic
|
||||
// displaySlideshowInterval is for display purposes only
|
||||
// keeping them separate and independant allows us to handle the logic however we want
|
||||
// while still displaying something that makes sense to the user
|
||||
const [slideshowInterval, setSlideshowInterval] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [
|
||||
displayedSlideshowInterval,
|
||||
setDisplayedSlideshowInterval,
|
||||
] = useState<string>(
|
||||
(userSelectedSlideshowDelayOrDefault / SECONDS_TO_MS).toString()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsSwitchingPage(false);
|
||||
@@ -59,6 +101,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
() => setInstantTransition(false),
|
||||
400
|
||||
);
|
||||
|
||||
const setInstant = useCallback(() => {
|
||||
setInstantTransition(true);
|
||||
disableInstantTransition();
|
||||
@@ -108,6 +151,28 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
}
|
||||
}, [initialIndex, isVisible, setIndex]);
|
||||
|
||||
const toggleSlideshow = useCallback(() => {
|
||||
if (slideshowInterval) {
|
||||
setSlideshowInterval(null);
|
||||
} else if (
|
||||
displayedSlideshowInterval !== null &&
|
||||
typeof displayedSlideshowInterval !== "undefined"
|
||||
) {
|
||||
const intervalNumber = Number.parseInt(displayedSlideshowInterval, 10);
|
||||
setSlideshowInterval(intervalNumber * SECONDS_TO_MS);
|
||||
} else {
|
||||
setSlideshowInterval(userSelectedSlideshowDelayOrDefault);
|
||||
}
|
||||
}, [
|
||||
slideshowInterval,
|
||||
userSelectedSlideshowDelayOrDefault,
|
||||
displayedSlideshowInterval,
|
||||
]);
|
||||
|
||||
usePageVisibility(() => {
|
||||
toggleSlideshow();
|
||||
});
|
||||
|
||||
const close = useCallback(() => {
|
||||
if (!isFullscreen) {
|
||||
hide();
|
||||
@@ -122,37 +187,52 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
if (nodeName === "DIV" || nodeName === "PICTURE") close();
|
||||
};
|
||||
|
||||
const handleLeft = useCallback(() => {
|
||||
if (isSwitchingPage || index.current === -1) return;
|
||||
const handleLeft = useCallback(
|
||||
(isUserAction = true) => {
|
||||
if (isSwitchingPage || index.current === -1) return;
|
||||
|
||||
if (index.current === 0) {
|
||||
if (pageCallback) {
|
||||
setIsSwitchingPage(true);
|
||||
setIndex(-1);
|
||||
// Check if calling page wants to swap page
|
||||
const repage = pageCallback(-1);
|
||||
if (!repage) {
|
||||
setIsSwitchingPage(false);
|
||||
if (index.current === 0) {
|
||||
if (pageCallback) {
|
||||
setIsSwitchingPage(true);
|
||||
setIndex(-1);
|
||||
// Check if calling page wants to swap page
|
||||
const repage = pageCallback(-1);
|
||||
if (!repage) {
|
||||
setIsSwitchingPage(false);
|
||||
setIndex(0);
|
||||
}
|
||||
} else setIndex(images.length - 1);
|
||||
} else setIndex((index.current ?? 0) - 1);
|
||||
|
||||
if (isUserAction && resetIntervalCallback.current) {
|
||||
resetIntervalCallback.current();
|
||||
}
|
||||
},
|
||||
[images, setIndex, pageCallback, isSwitchingPage, resetIntervalCallback]
|
||||
);
|
||||
|
||||
const handleRight = useCallback(
|
||||
(isUserAction = true) => {
|
||||
if (isSwitchingPage) return;
|
||||
|
||||
if (index.current === images.length - 1) {
|
||||
if (pageCallback) {
|
||||
setIsSwitchingPage(true);
|
||||
setIndex(0);
|
||||
}
|
||||
} else setIndex(images.length - 1);
|
||||
} else setIndex((index.current ?? 0) - 1);
|
||||
}, [images, setIndex, pageCallback, isSwitchingPage]);
|
||||
const handleRight = useCallback(() => {
|
||||
if (isSwitchingPage) return;
|
||||
const repage = pageCallback?.(1);
|
||||
if (!repage) {
|
||||
setIsSwitchingPage(false);
|
||||
setIndex(images.length - 1);
|
||||
}
|
||||
} else setIndex(0);
|
||||
} else setIndex((index.current ?? 0) + 1);
|
||||
|
||||
if (index.current === images.length - 1) {
|
||||
if (pageCallback) {
|
||||
setIsSwitchingPage(true);
|
||||
setIndex(0);
|
||||
const repage = pageCallback?.(1);
|
||||
if (!repage) {
|
||||
setIsSwitchingPage(false);
|
||||
setIndex(images.length - 1);
|
||||
}
|
||||
} else setIndex(0);
|
||||
} else setIndex((index.current ?? 0) + 1);
|
||||
}, [images, setIndex, pageCallback, isSwitchingPage]);
|
||||
if (isUserAction && resetIntervalCallback.current) {
|
||||
resetIntervalCallback.current();
|
||||
}
|
||||
},
|
||||
[images, setIndex, pageCallback, isSwitchingPage, resetIntervalCallback]
|
||||
);
|
||||
|
||||
const handleKey = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
@@ -164,8 +244,12 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
},
|
||||
[setInstant, handleLeft, handleRight, close]
|
||||
);
|
||||
const handleFullScreenChange = () =>
|
||||
const handleFullScreenChange = () => {
|
||||
if (clearIntervalCallback.current) {
|
||||
clearIntervalCallback.current();
|
||||
}
|
||||
setFullscreen(document.fullscreenElement !== null);
|
||||
};
|
||||
|
||||
const handleTouchStart = (ev: React.TouchEvent<HTMLDivElement>) => {
|
||||
setInstantTransition(true);
|
||||
@@ -212,6 +296,16 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
el.addEventListener("touchcancel", handleCancel);
|
||||
};
|
||||
|
||||
const [clearCallback, resetCallback] = useInterval(
|
||||
() => {
|
||||
handleRight(false);
|
||||
},
|
||||
slideshowEnabled ? slideshowInterval : null
|
||||
);
|
||||
|
||||
resetIntervalCallback.current = resetCallback;
|
||||
clearIntervalCallback.current = clearCallback;
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
document.addEventListener("keydown", handleKey);
|
||||
@@ -228,6 +322,10 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
else document.exitFullscreen();
|
||||
}, [isFullscreen]);
|
||||
|
||||
const handleSlideshowIntervalChange = (newSlideshowInterval: number) => {
|
||||
setSlideshowInterval(newSlideshowInterval);
|
||||
};
|
||||
|
||||
const navItems = images.map((image, i) => (
|
||||
<img
|
||||
src={image.paths.thumbnail ?? ""}
|
||||
@@ -242,40 +340,120 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
/>
|
||||
));
|
||||
|
||||
const onDelayChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let numberValue = Number.parseInt(e.currentTarget.value, 10);
|
||||
// Without this exception, the blocking of updates for invalid values is even weirder
|
||||
if (e.currentTarget.value === "-" || e.currentTarget.value === "") {
|
||||
setDisplayedSlideshowInterval(e.currentTarget.value);
|
||||
return;
|
||||
}
|
||||
|
||||
setDisplayedSlideshowInterval(e.currentTarget.value);
|
||||
if (slideshowInterval !== null) {
|
||||
numberValue =
|
||||
numberValue >= MIN_VALID_INTERVAL_SECONDS
|
||||
? numberValue
|
||||
: MIN_VALID_INTERVAL_SECONDS;
|
||||
handleSlideshowIntervalChange(numberValue * SECONDS_TO_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const currentIndex = index.current === null ? initialIndex : index.current;
|
||||
|
||||
const DelayForm: React.FC<{}> = () => (
|
||||
<>
|
||||
<FormLabel column sm="5">
|
||||
Delay (Sec)
|
||||
</FormLabel>
|
||||
<Col sm="4">
|
||||
<FormControl
|
||||
type="number"
|
||||
className="text-input"
|
||||
min={1}
|
||||
value={displayedSlideshowInterval ?? 0}
|
||||
onChange={onDelayChange}
|
||||
size="sm"
|
||||
id="delay-input"
|
||||
/>
|
||||
</Col>
|
||||
</>
|
||||
);
|
||||
|
||||
const delayPopover = (
|
||||
<Popover id="basic-bitch">
|
||||
<Popover.Title>Set slideshow delay</Popover.Title>
|
||||
<Popover.Content>
|
||||
<InputGroup>
|
||||
<DelayForm />
|
||||
</InputGroup>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
const element = isVisible ? (
|
||||
<div
|
||||
className={CLASSNAME}
|
||||
role="presentation"
|
||||
ref={containerRef}
|
||||
onClick={handleClose}
|
||||
onMouseDown={handleClose}
|
||||
>
|
||||
{images.length > 0 && !isLoading && !isSwitchingPage ? (
|
||||
<>
|
||||
<div className={CLASSNAME_HEADER}>
|
||||
<div className={CLASSNAME_LEFT_SPACER} />
|
||||
<div className={CLASSNAME_INDICATOR}>
|
||||
<span>{pageHeader}</span>
|
||||
<b ref={indicatorRef}>
|
||||
{`${currentIndex + 1} / ${images.length}`}
|
||||
</b>
|
||||
</div>
|
||||
{document.fullscreenEnabled && (
|
||||
<div className={CLASSNAME_RIGHT}>
|
||||
{slideshowEnabled && (
|
||||
<>
|
||||
<div className={CLASSNAME_DELAY}>
|
||||
<div className={CLASSNAME_DELAY_ICON}>
|
||||
<OverlayTrigger
|
||||
trigger="click"
|
||||
placement="bottom"
|
||||
overlay={delayPopover}
|
||||
>
|
||||
<Button variant="link" title="Slideshow delay settings">
|
||||
<Icon icon="cog" />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
</div>
|
||||
<InputGroup className={CLASSNAME_DELAY_INLINE}>
|
||||
<DelayForm />
|
||||
</InputGroup>
|
||||
</div>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={toggleSlideshow}
|
||||
title="Toggle Slideshow"
|
||||
>
|
||||
<Icon
|
||||
icon={slideshowInterval !== null ? "pause" : "play"}
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{document.fullscreenEnabled && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={toggleFullscreen}
|
||||
title="Toggle Fullscreen"
|
||||
>
|
||||
<Icon icon="expand" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={toggleFullscreen}
|
||||
title="Toggle Fullscreen"
|
||||
onClick={() => close()}
|
||||
title="Close Lightbox"
|
||||
>
|
||||
<Icon icon="expand" />
|
||||
<Icon icon="times" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => close()}
|
||||
title="Close Lightbox"
|
||||
>
|
||||
<Icon icon="times" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={CLASSNAME_DISPLAY} onTouchStart={handleTouchStart}>
|
||||
{images.length > 1 && (
|
||||
|
||||
Reference in New Issue
Block a user