import React, { useEffect, useMemo, useRef, useState } from "react"; import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap"; import { useHistory } from "react-router-dom"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import { GalleryLink, TagLink, SceneMarkerLink } from "../Shared/TagLink"; import { HoverPopover } from "../Shared/HoverPopover"; import { SweatDrops } from "../Shared/SweatDrops"; import { TruncatedText } from "../Shared/TruncatedText"; import NavUtils from "src/utils/navigation"; import TextUtils from "src/utils/text"; import { SceneQueue } from "src/models/sceneQueue"; import { ConfigurationContext } from "src/hooks/Config"; import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard"; import { RatingBanner } from "../Shared/RatingBanner"; import { FormattedMessage, FormattedNumber } from "react-intl"; import { faBox, faCopy, faFilm, faImages, faMapMarkerAlt, faTag, } from "@fortawesome/free-solid-svg-icons"; import { objectPath, objectTitle } from "src/core/files"; import { PreviewScrubber } from "./PreviewScrubber"; import { PatchComponent } from "src/patch"; import ScreenUtils from "src/utils/screen"; import { StudioOverlay } from "../Shared/GridCard/StudioOverlay"; import { GroupTag } from "../Groups/GroupTag"; interface IScenePreviewProps { isPortrait: boolean; image?: string; video?: string; soundActive: boolean; vttPath?: string; onScrubberClick?: (timestamp: number) => void; } export const ScenePreview: React.FC = ({ image, video, isPortrait, soundActive, vttPath, onScrubberClick, }) => { const videoEl = useRef(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(); }); }); if (videoEl.current) observer.observe(videoEl.current); }); useEffect(() => { if (videoEl?.current?.volume) videoEl.current.volume = soundActive ? 0.05 : 0; }, [soundActive]); return (
); }; interface ISceneCardProps { scene: GQL.SlimSceneDataFragment; containerWidth?: number; previewHeight?: number; index?: number; queue?: SceneQueue; compact?: boolean; selecting?: boolean; selected?: boolean | undefined; zoomIndex?: number; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; fromGroupId?: string; } const Description: React.FC<{ sceneNumber?: number; }> = ({ sceneNumber }) => { if (!sceneNumber) return null; return ( <>
{sceneNumber !== undefined && ( #{sceneNumber} )} ); }; const SceneCardPopovers = PatchComponent( "SceneCard.Popovers", (props: ISceneCardProps) => { const file = useMemo( () => (props.scene.files.length > 0 ? props.scene.files[0] : undefined), [props.scene] ); const sceneNumber = useMemo(() => { if (!props.fromGroupId) { return undefined; } const group = props.scene.groups.find( (g) => g.group.id === props.fromGroupId ); return group?.scene_index ?? undefined; }, [props.fromGroupId, props.scene.groups]); function maybeRenderTagPopoverButton() { if (props.scene.tags.length <= 0) return; const popoverContent = props.scene.tags.map((tag) => ( )); return ( ); } function maybeRenderPerformerPopoverButton() { if (props.scene.performers.length <= 0) return; return ( ); } function maybeRenderGroupPopoverButton() { if (props.scene.groups.length <= 0) return; const popoverContent = props.scene.groups.map((sceneGroup) => ( )); return ( ); } function maybeRenderSceneMarkerPopoverButton() { if (props.scene.scene_markers.length <= 0) return; const popoverContent = props.scene.scene_markers.map((marker) => { const markerWithScene = { ...marker, scene: { id: props.scene.id } }; return ; }); return ( ); } function maybeRenderOCounter() { if (props.scene.o_counter) { return (
); } } function maybeRenderGallery() { if (props.scene.galleries.length <= 0) return; const popoverContent = props.scene.galleries.map((gallery) => ( )); return ( ); } function maybeRenderOrganized() { if (props.scene.organized) { return ( {"Organized"}} placement="bottom" >
); } } function maybeRenderDupeCopies() { const phash = file ? file.fingerprints.find((fp) => fp.type === "phash") : undefined; if (phash) { return (
); } } function maybeRenderPopoverButtonGroup() { if ( !props.compact && (props.scene.tags.length > 0 || props.scene.performers.length > 0 || props.scene.groups.length > 0 || props.scene.scene_markers.length > 0 || props.scene?.o_counter || props.scene.galleries.length > 0 || props.scene.organized || sceneNumber !== undefined) ) { return ( <>
{maybeRenderTagPopoverButton()} {maybeRenderPerformerPopoverButton()} {maybeRenderGroupPopoverButton()} {maybeRenderSceneMarkerPopoverButton()} {maybeRenderOCounter()} {maybeRenderGallery()} {maybeRenderOrganized()} {maybeRenderDupeCopies()} ); } } return <>{maybeRenderPopoverButtonGroup()}; } ); const SceneCardDetails = PatchComponent( "SceneCard.Details", (props: ISceneCardProps) => { return (
{props.scene.date} {objectPath(props.scene)}
); } ); const SceneCardOverlays = PatchComponent( "SceneCard.Overlays", (props: ISceneCardProps) => { return ; } ); const SceneCardImage = PatchComponent( "SceneCard.Image", (props: ISceneCardProps) => { const history = useHistory(); const { configuration } = React.useContext(ConfigurationContext); const cont = configuration?.interface.continuePlaylistDefault ?? false; const file = useMemo( () => (props.scene.files.length > 0 ? props.scene.files[0] : undefined), [props.scene] ); function maybeRenderSceneSpecsOverlay() { let sizeObj = null; if (file?.size) { sizeObj = TextUtils.fileSize(file.size); } return (
{sizeObj != null ? ( {TextUtils.formatFileSizeUnit(sizeObj.unit)} ) : ( "" )} {file?.width && file?.height ? ( {" "} {TextUtils.resolution(file?.width, file?.height)} ) : ( "" )} {(file?.duration ?? 0) >= 1 ? ( {TextUtils.secondsToTimestamp(file?.duration ?? 0)} ) : ( "" )}
); } function maybeRenderInteractiveSpeedOverlay() { return (
{props.scene.interactive_speed ?? ""}
); } function onScrubberClick(timestamp: number) { const link = props.queue ? props.queue.makeLink(props.scene.id, { sceneIndex: props.index, continue: cont, start: timestamp, }) : `/scenes/${props.scene.id}?t=${timestamp}`; history.push(link); } function isPortrait() { const width = file?.width ? file.width : 0; const height = file?.height ? file.height : 0; return height > width; } return ( <> {maybeRenderSceneSpecsOverlay()} {maybeRenderInteractiveSpeedOverlay()} ); } ); export const SceneCard = PatchComponent( "SceneCard", (props: ISceneCardProps) => { const { configuration } = React.useContext(ConfigurationContext); const [cardWidth, setCardWidth] = useState(); const file = useMemo( () => (props.scene.files.length > 0 ? props.scene.files[0] : undefined), [props.scene] ); function zoomIndex() { if (!props.compact && props.zoomIndex !== undefined) { return `zoom-${props.zoomIndex}`; } return ""; } function filelessClass() { if (!props.scene.files.length) { return "fileless"; } return ""; } useEffect(() => { if ( !props.containerWidth || props.zoomIndex === undefined || ScreenUtils.isMobile() ) return; let zoomValue = props.zoomIndex; let preferredCardWidth: number; switch (zoomValue) { case 0: preferredCardWidth = 240; break; case 1: preferredCardWidth = 340; // this value is intentionally higher than 320 break; case 2: preferredCardWidth = 480; break; case 3: preferredCardWidth = 640; } let fittedCardWidth = calculateCardWidth( props.containerWidth, preferredCardWidth! ); setCardWidth(fittedCardWidth); }, [props, props.containerWidth, props.zoomIndex]); const cont = configuration?.interface.continuePlaylistDefault ?? false; const sceneLink = props.queue ? props.queue.makeLink(props.scene.id, { sceneIndex: props.index, continue: cont, }) : `/scenes/${props.scene.id}`; return ( } overlays={} details={} popovers={} selected={props.selected} selecting={props.selecting} onSelectedChanged={props.onSelectedChanged} /> ); } );