mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
Scene card preview refactor (#787)
* Refactor scene card preview * Add delay to video preview
This commit is contained in:
@@ -1,13 +1,62 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { Button, ButtonGroup, Card, Form } from "react-bootstrap";
|
import { Button, ButtonGroup, Card, Form } from "react-bootstrap";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { useConfiguration } from "src/core/StashService";
|
import { useConfiguration } from "src/core/StashService";
|
||||||
import { useVideoHover } from "src/hooks";
|
|
||||||
import { Icon, TagLink, HoverPopover, SweatDrops } from "src/components/Shared";
|
import { Icon, TagLink, HoverPopover, SweatDrops } from "src/components/Shared";
|
||||||
import { TextUtils } from "src/utils";
|
import { TextUtils } from "src/utils";
|
||||||
|
|
||||||
|
interface IScenePreviewProps {
|
||||||
|
isPortrait: boolean;
|
||||||
|
image?: string;
|
||||||
|
video?: string;
|
||||||
|
soundActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScenePreview: React.FC<IScenePreviewProps> = ({
|
||||||
|
image,
|
||||||
|
video,
|
||||||
|
isPortrait,
|
||||||
|
soundActive,
|
||||||
|
}) => {
|
||||||
|
const videoEl = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.intersectionRatio > 0)
|
||||||
|
// Catch is necessary due to DOMException if user hovers before clicking on page
|
||||||
|
videoEl.current?.play().catch(() => {});
|
||||||
|
else videoEl.current?.pause();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ root: document.documentElement }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (videoEl.current) observer.observe(videoEl.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoEl?.current?.volume)
|
||||||
|
videoEl.current.volume = soundActive ? 0.05 : 0;
|
||||||
|
}, [soundActive]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx("scene-card-preview", { portrait: isPortrait })}>
|
||||||
|
<img className="scene-card-preview-image" src={image} alt="" />
|
||||||
|
<video
|
||||||
|
className="scene-card-preview-video"
|
||||||
|
loop
|
||||||
|
preload="none"
|
||||||
|
ref={videoEl}
|
||||||
|
src={video}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface ISceneCardProps {
|
interface ISceneCardProps {
|
||||||
scene: GQL.SlimSceneDataFragment;
|
scene: GQL.SlimSceneDataFragment;
|
||||||
selecting?: boolean;
|
selecting?: boolean;
|
||||||
@@ -19,11 +68,6 @@ interface ISceneCardProps {
|
|||||||
export const SceneCard: React.FC<ISceneCardProps> = (
|
export const SceneCard: React.FC<ISceneCardProps> = (
|
||||||
props: ISceneCardProps
|
props: ISceneCardProps
|
||||||
) => {
|
) => {
|
||||||
const [previewPath, setPreviewPath] = useState<string>();
|
|
||||||
const hoverHandler = useVideoHover({
|
|
||||||
resetOnMouseLeave: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const config = useConfiguration();
|
const config = useConfiguration();
|
||||||
const showStudioAsText =
|
const showStudioAsText =
|
||||||
config?.data?.configuration.interface.showStudioAsText ?? false;
|
config?.data?.configuration.interface.showStudioAsText ?? false;
|
||||||
@@ -220,18 +264,6 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseEnter() {
|
|
||||||
if (!previewPath || previewPath === "") {
|
|
||||||
setPreviewPath(props.scene.paths.preview || "");
|
|
||||||
}
|
|
||||||
hoverHandler.onMouseEnter();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMouseLeave() {
|
|
||||||
hoverHandler.onMouseLeave();
|
|
||||||
setPreviewPath("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSceneClick(
|
function handleSceneClick(
|
||||||
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
||||||
) {
|
) {
|
||||||
@@ -272,11 +304,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
|||||||
let shiftKey = false;
|
let shiftKey = false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card className={`scene-card zoom-${props.zoomIndex}`}>
|
||||||
className={`scene-card zoom-${props.zoomIndex}`}
|
|
||||||
onMouseEnter={onMouseEnter}
|
|
||||||
onMouseLeave={onMouseLeave}
|
|
||||||
>
|
|
||||||
<Form.Control
|
<Form.Control
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="scene-card-check"
|
className="scene-card-check"
|
||||||
@@ -298,16 +326,16 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
|||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
draggable={props.selecting}
|
draggable={props.selecting}
|
||||||
>
|
>
|
||||||
|
<ScenePreview
|
||||||
|
image={props.scene.paths.screenshot ?? undefined}
|
||||||
|
video={props.scene.paths.preview ?? undefined}
|
||||||
|
isPortrait={isPortrait()}
|
||||||
|
soundActive={
|
||||||
|
config.data?.configuration?.interface?.soundOnPreview ?? false
|
||||||
|
}
|
||||||
|
/>
|
||||||
{maybeRenderRatingBanner()}
|
{maybeRenderRatingBanner()}
|
||||||
{maybeRenderSceneSpecsOverlay()}
|
{maybeRenderSceneSpecsOverlay()}
|
||||||
<video
|
|
||||||
loop
|
|
||||||
className={cx("scene-card-video", { portrait: isPortrait() })}
|
|
||||||
poster={props.scene.paths.screenshot || ""}
|
|
||||||
ref={hoverHandler.videoEl}
|
|
||||||
>
|
|
||||||
{previewPath ? <source src={previewPath} /> : ""}
|
|
||||||
</video>
|
|
||||||
</Link>
|
</Link>
|
||||||
{maybeRenderSceneStudioOverlay()}
|
{maybeRenderSceneStudioOverlay()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ textarea.scene-description {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-card-check {
|
&-check {
|
||||||
left: 0.5rem;
|
left: 0.5rem;
|
||||||
margin-top: -12px;
|
margin-top: -12px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -190,6 +190,34 @@ textarea.scene-description {
|
|||||||
transition: opacity 0.5s;
|
transition: opacity 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-preview {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&-image,
|
||||||
|
&-video {
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-video {
|
||||||
|
position: absolute;
|
||||||
|
top: -9999px;
|
||||||
|
transition: top 0s;
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.portrait {
|
||||||
|
.scene-card-preview-image,
|
||||||
|
.scene-card-preview-video {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.scene-specs-overlay,
|
.scene-specs-overlay,
|
||||||
.rating-banner,
|
.rating-banner,
|
||||||
@@ -207,6 +235,11 @@ textarea.scene-description {
|
|||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
transition: opacity 0.5s;
|
transition: opacity 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scene-card-preview-video {
|
||||||
|
top: 0;
|
||||||
|
transition-delay: 0.2s;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useConfiguration } from "../core/StashService";
|
|
||||||
|
|
||||||
export interface IVideoHoverHookData {
|
|
||||||
videoEl: React.RefObject<HTMLVideoElement>;
|
|
||||||
isPlaying: React.MutableRefObject<boolean>;
|
|
||||||
isHovering: React.MutableRefObject<boolean>;
|
|
||||||
options: IVideoHoverHookOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IVideoHoverHookOptions {
|
|
||||||
resetOnMouseLeave: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useVideoHover = (options: IVideoHoverHookOptions) => {
|
|
||||||
const videoEl = useRef<HTMLVideoElement>(null);
|
|
||||||
const isPlaying = useRef<boolean>(false);
|
|
||||||
const isHovering = useRef<boolean>(false);
|
|
||||||
const config = useConfiguration();
|
|
||||||
|
|
||||||
const onMouseEnter = () => {
|
|
||||||
isHovering.current = true;
|
|
||||||
|
|
||||||
const videoTag = videoEl.current;
|
|
||||||
if (!videoTag) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (videoTag.paused && !isPlaying.current) {
|
|
||||||
videoTag.play().catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseLeave = () => {
|
|
||||||
isHovering.current = false;
|
|
||||||
|
|
||||||
const videoTag = videoEl.current;
|
|
||||||
if (!videoTag) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!videoTag.paused && isPlaying) {
|
|
||||||
videoTag.pause();
|
|
||||||
if (options.resetOnMouseLeave) {
|
|
||||||
videoTag.removeAttribute("src");
|
|
||||||
videoTag.load();
|
|
||||||
isPlaying.current = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const soundEnabled =
|
|
||||||
config?.data?.configuration?.interface?.soundOnPreview ?? true;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const videoTag = videoEl.current;
|
|
||||||
if (!videoTag) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
videoTag.onplaying = () => {
|
|
||||||
if (isHovering.current === true) {
|
|
||||||
isPlaying.current = true;
|
|
||||||
} else {
|
|
||||||
videoTag.pause();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
videoTag.onpause = () => {
|
|
||||||
isPlaying.current = false;
|
|
||||||
};
|
|
||||||
}, [videoEl]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const videoTag = videoEl.current;
|
|
||||||
if (!videoTag) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
videoTag.volume = soundEnabled ? 0.05 : 0;
|
|
||||||
}, [soundEnabled]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
videoEl,
|
|
||||||
isPlaying,
|
|
||||||
isHovering,
|
|
||||||
options,
|
|
||||||
onMouseEnter,
|
|
||||||
onMouseLeave,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
export { default as useToast } from "./Toast";
|
export { default as useToast } from "./Toast";
|
||||||
export { useInterfaceLocalForage, useChangelogStorage } from "./LocalForage";
|
export { useInterfaceLocalForage, useChangelogStorage } from "./LocalForage";
|
||||||
export { useVideoHover } from "./VideoHover";
|
|
||||||
export {
|
export {
|
||||||
useScenesList,
|
useScenesList,
|
||||||
useSceneMarkersList,
|
useSceneMarkersList,
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ textarea.text-input {
|
|||||||
.zoom-0 {
|
.zoom-0 {
|
||||||
width: 240px;
|
width: 240px;
|
||||||
|
|
||||||
.scene-card-video {
|
.scene-card-preview {
|
||||||
height: 135px;
|
height: 135px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ textarea.text-input {
|
|||||||
.zoom-1 {
|
.zoom-1 {
|
||||||
width: 320px;
|
width: 320px;
|
||||||
|
|
||||||
.scene-card-video {
|
.scene-card-preview {
|
||||||
height: 180px;
|
height: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ textarea.text-input {
|
|||||||
.zoom-2 {
|
.zoom-2 {
|
||||||
width: 480px;
|
width: 480px;
|
||||||
|
|
||||||
.scene-card-video {
|
.scene-card-preview {
|
||||||
height: 270px;
|
height: 270px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ textarea.text-input {
|
|||||||
.zoom-3 {
|
.zoom-3 {
|
||||||
width: 640px;
|
width: 640px;
|
||||||
|
|
||||||
.scene-card-video {
|
.scene-card-preview {
|
||||||
height: 360px;
|
height: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,15 +165,7 @@ textarea.text-input {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-card-video {
|
.scene-card-preview,
|
||||||
object-fit: cover;
|
|
||||||
|
|
||||||
&.portrait {
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-card-video,
|
|
||||||
.gallery-card-image,
|
.gallery-card-image,
|
||||||
.tag-card-image {
|
.tag-card-image {
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user