[Feature] Added slideshow to gallery in wall display mode (#1224)

This commit is contained in:
Elad Lachmi 2021-04-13 07:59:37 +03:00 committed by GitHub
parent 6a4421f8e1
commit f443223d16
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 463 additions and 55 deletions

View file

@ -2,7 +2,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
stashes {
path
excludeVideo
excludeImage
excludeImage
}
databasePath
generatedPath
@ -52,6 +52,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
css
cssEnabled
language
slideshowDelay
}
fragment ConfigData on ConfigResult {

View file

@ -188,6 +188,8 @@ input ConfigInterfaceInput {
cssEnabled: Boolean
"""Interface language"""
language: String
"""Slideshow Delay"""
slideshowDelay: Int
}
type ConfigInterfaceResult {
@ -210,6 +212,8 @@ type ConfigInterfaceResult {
cssEnabled: Boolean
"""Interface language"""
language: String
"""Slideshow Delay"""
slideshowDelay: Int
}
"""All configuration settings"""

View file

@ -219,6 +219,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
c.Set(config.Language, *input.Language)
}
if input.SlideshowDelay != nil {
c.Set(config.SlideshowDelay, *input.SlideshowDelay)
}
css := ""
if input.CSS != nil {

View file

@ -93,6 +93,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
css := config.GetCSS()
cssEnabled := config.GetCSSEnabled()
language := config.GetLanguage()
slideshowDelay := config.GetSlideshowDelay()
return &models.ConfigInterfaceResult{
MenuItems: menuItems,
@ -105,5 +106,6 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
CSS: &css,
CSSEnabled: &cssEnabled,
Language: &language,
SlideshowDelay: &slideshowDelay,
}
}

View file

@ -118,6 +118,7 @@ const AutostartVideo = "autostart_video"
const ShowStudioAsText = "show_studio_as_text"
const CSSEnabled = "cssEnabled"
const WallPlayback = "wall_playback"
const SlideshowDelay = "slideshow_delay"
// Logging options
const LogFile = "logFile"
@ -560,6 +561,11 @@ func (i *Instance) GetShowStudioAsText() bool {
return viper.GetBool(ShowStudioAsText)
}
func (i *Instance) GetSlideshowDelay() int {
viper.SetDefault(SlideshowDelay, 5000)
return viper.GetInt(SlideshowDelay)
}
func (i *Instance) GetCSSPath() string {
// use custom.css in the same directory as the config file
configFileUsed := viper.ConfigFileUsed()

View file

@ -4,6 +4,7 @@
* Added scene queue.
### 🎨 Improvements
* Add slideshow to image wall view.
* Support API key via URL query parameter, and added API key to stream link in Scene File Info.
* Revamped setup wizard and migration UI.
* Add various `count` filter criteria and sort options.

View file

@ -17,6 +17,7 @@ import {
showWhenSelected,
PersistanceLevel,
} from "src/hooks/ListHook";
import { ImageCard } from "./ImageCard";
import { EditImagesDialog } from "./EditImagesDialog";
import { DeleteImagesDialog } from "./DeleteImagesDialog";
@ -36,34 +37,57 @@ const ImageWall: React.FC<IImageWallProps> = ({
currentPage,
pageCount,
}) => {
const [slideshowRunning, setSlideshowRunning] = useState<boolean>(false);
const handleLightBoxPage = useCallback(
(direction: number) => {
if (direction === -1) {
if (currentPage === 1) return false;
onChangePage(currentPage - 1);
} else {
if (currentPage === pageCount) return false;
if (currentPage === pageCount) {
// if the slideshow is running
// return to the first page
if (slideshowRunning) {
onChangePage(0);
return true;
}
return false;
}
onChangePage(currentPage + 1);
}
return direction === -1 || direction === 1;
},
[onChangePage, currentPage, pageCount]
[onChangePage, currentPage, pageCount, slideshowRunning]
);
const handleClose = useCallback(() => {
setSlideshowRunning(false);
}, [setSlideshowRunning]);
const showLightbox = useLightbox({
images,
showNavigation: false,
pageCallback: handleLightBoxPage,
pageHeader: `Page ${currentPage} / ${pageCount}`,
slideshowEnabled: slideshowRunning,
onClose: handleClose,
});
const handleImageOpen = useCallback(
(index) => {
setSlideshowRunning(true);
showLightbox(index, true);
},
[showLightbox]
);
const thumbs = images.map((image, index) => (
<div
role="link"
tabIndex={index}
key={image.id}
onClick={() => showLightbox(index)}
onKeyPress={() => showLightbox(index)}
onClick={() => handleImageOpen(index)}
onKeyPress={() => handleImageOpen(index)}
>
<img
src={image.paths.thumbnail ?? ""}

View file

@ -16,6 +16,8 @@ const allMenuItems = [
{ id: "tags", label: "Tags" },
];
const SECONDS_TO_MS = 1000;
export const SettingsInterfacePanel: React.FC = () => {
const Toast = useToast();
const { data: config, error, loading } = useConfiguration();
@ -27,6 +29,7 @@ export const SettingsInterfacePanel: React.FC = () => {
const [wallPlayback, setWallPlayback] = useState<string>("video");
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
const [autostartVideo, setAutostartVideo] = useState<boolean>(false);
const [slideshowDelay, setSlideshowDelay] = useState<number>(0);
const [showStudioAsText, setShowStudioAsText] = useState<boolean>(false);
const [css, setCSS] = useState<string>();
const [cssEnabled, setCSSEnabled] = useState<boolean>(false);
@ -43,6 +46,7 @@ export const SettingsInterfacePanel: React.FC = () => {
css,
cssEnabled,
language,
slideshowDelay,
});
useEffect(() => {
@ -57,6 +61,7 @@ export const SettingsInterfacePanel: React.FC = () => {
setCSS(iCfg?.css ?? "");
setCSSEnabled(iCfg?.cssEnabled ?? false);
setLanguage(iCfg?.language ?? "en-US");
setSlideshowDelay(iCfg?.slideshowDelay ?? 5000);
}, [config]);
async function onSave() {
@ -187,6 +192,23 @@ export const SettingsInterfacePanel: React.FC = () => {
</Form.Group>
</Form.Group>
<Form.Group id="slideshow-delay">
<h5>Slideshow Delay</h5>
<Form.Control
className="col col-sm-6 text-input"
type="number"
value={slideshowDelay / SECONDS_TO_MS}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSlideshowDelay(
Number.parseInt(e.currentTarget.value, 10) * SECONDS_TO_MS
);
}}
/>
<Form.Text className="text-muted">
Slideshow is available in galleries when in wall view mode
</Form.Text>
</Form.Group>
<Form.Group>
<h5>Custom CSS</h5>
<Form.Check

View file

@ -0,0 +1,65 @@
import { useEffect, useRef, useState } from "react";
import noop from "lodash/noop";
const MIN_VALID_INTERVAL = 1000;
const useInterval = (
callback: () => void,
delay: number | null = 5000
): (() => void)[] => {
const savedCallback = useRef<() => void>();
const savedIntervalId = useRef<NodeJS.Timeout>();
const [savedDelay, setSavedDelay] = useState<number | null>(delay);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
let validDelay;
if (delay !== null) {
validDelay = delay >= MIN_VALID_INTERVAL ? delay : MIN_VALID_INTERVAL;
} else {
validDelay = delay;
}
setSavedDelay(validDelay);
}, [delay]);
const cancel = () => {
const intervalId = savedIntervalId.current;
if (intervalId) {
savedIntervalId.current = undefined;
clearInterval(intervalId);
}
};
const reset = () => {
cancel();
const tick = () => {
if (savedCallback.current) savedCallback.current();
};
if (savedDelay !== null) {
savedIntervalId.current = setInterval(tick, savedDelay);
}
};
useEffect(() => {
cancel();
const tick = () => {
if (savedCallback.current) savedCallback.current();
};
if (savedDelay !== null) {
savedIntervalId.current = setInterval(tick, savedDelay);
return cancel;
}
}, [callback, savedDelay]);
return delay ? [cancel, reset] : [noop, noop];
};
export default useInterval;

View file

@ -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 && (

View file

@ -12,6 +12,8 @@ export interface IState {
initialIndex?: number;
pageCallback?: (direction: number) => boolean;
pageHeader?: string;
slideshowEnabled: boolean;
onClose?: () => void;
}
interface IContext {
setLightboxState: (state: Partial<IState>) => void;
@ -26,6 +28,7 @@ const Lightbox: React.FC = ({ children }) => {
isVisible: false,
isLoading: false,
showNavigation: true,
slideshowEnabled: false,
});
const setPartialState = useCallback(
@ -38,14 +41,18 @@ const Lightbox: React.FC = ({ children }) => {
[setLightboxState]
);
const onHide = () => {
setLightboxState({ ...lightboxState, isVisible: false });
if (lightboxState.onClose) {
lightboxState.onClose();
}
};
return (
<LightboxContext.Provider value={{ setLightboxState: setPartialState }}>
{children}
{lightboxState.isVisible && (
<LightboxComponent
{...lightboxState}
hide={() => setLightboxState({ ...lightboxState, isVisible: false })}
/>
<LightboxComponent {...lightboxState} hide={onHide} />
)}
</LightboxContext.Provider>
);

View file

@ -12,6 +12,8 @@ export const useLightbox = (state: Partial<Omit<IState, "isVisible">>) => {
pageCallback: state.pageCallback,
initialIndex: state.initialIndex,
pageHeader: state.pageHeader,
slideshowEnabled: state.slideshowEnabled,
onClose: state.onClose,
});
}, [
setLightboxState,
@ -20,13 +22,16 @@ export const useLightbox = (state: Partial<Omit<IState, "isVisible">>) => {
state.pageCallback,
state.initialIndex,
state.pageHeader,
state.slideshowEnabled,
state.onClose,
]);
const show = useCallback(
(index?: number) => {
(index?: number, slideshowEnabled = false) => {
setLightboxState({
initialIndex: index,
isVisible: true,
slideshowEnabled,
});
},
[setLightboxState]

View file

@ -26,14 +26,51 @@
flex-shrink: 0;
height: 4rem;
&-left-spacer {
display: flex;
flex: 1;
justify-content: center;
}
&-indicator {
display: flex;
flex: 1;
flex-direction: column;
margin-left: 49%;
margin-right: auto;
text-align: center;
}
&-delay {
display: flex;
flex-direction: column;
margin-left: 100px;
text-align: left;
&-icon {
display: inline-block;
}
&-inline {
display: none;
}
@media screen and (min-width: 1300px) {
&-icon {
display: none;
}
&-inline {
display: flex;
}
}
}
&-right {
display: flex;
flex: 1;
justify-content: flex-end;
}
.fa-icon {
height: 1.5rem;
opacity: 1;

View file

@ -0,0 +1,50 @@
import { useEffect, useRef } from "react";
const usePageVisibility = (visibilityChangeCallback: () => void): void => {
const savedVisibilityChangedCallback = useRef<() => void>();
useEffect(() => {
// resolve event names for different browsers
let hidden = "";
let visibilityChange = "";
if (typeof document.hidden !== "undefined") {
hidden = "hidden";
visibilityChange = "visibilitychange";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} else if (typeof (document as any).msHidden !== "undefined") {
hidden = "msHidden";
visibilityChange = "msvisibilitychange";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} else if (typeof (document as any).webkitHidden !== "undefined") {
hidden = "webkitHidden";
visibilityChange = "webkitvisibilitychange";
}
if (
typeof document.addEventListener === "undefined" ||
hidden === undefined
) {
// this browser doesn't have support for modern event listeners or the Page Visibility API
return;
}
savedVisibilityChangedCallback.current = visibilityChangeCallback;
document.addEventListener(
visibilityChange,
savedVisibilityChangedCallback.current
);
return () => {
if (savedVisibilityChangedCallback.current) {
document.removeEventListener(
visibilityChange,
savedVisibilityChangedCallback.current
);
}
};
}, [visibilityChangeCallback]);
};
export default usePageVisibility;

View file

@ -1,4 +1,6 @@
export { default as useToast } from "./Toast";
export { default as useInterval } from "./Interval";
export { default as usePageVisibility } from "./PageVisibility";
export {
useInterfaceLocalForage,
useChangelogStorage,