From 8e697b50eb55b13e4694f9ef8210a0595f3d4168 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 2 Jun 2025 17:18:36 +1000 Subject: [PATCH] Revamp scene and marker wall views (#5816) * Use gallery for scene wall * Move into separate file * Remove unnecessary class names * Apply configuration * Reuse styling * Add Scene Marker wall panel * Adjust target row height --- ui/v2.5/src/components/Scenes/SceneList.tsx | 2 +- .../src/components/Scenes/SceneMarkerList.tsx | 2 +- .../Scenes/SceneMarkerWallPanel.tsx | 234 ++++++++++++++++++ .../src/components/Scenes/SceneWallPanel.tsx | 220 ++++++++++++++++ ui/v2.5/src/components/Scenes/styles.scss | 59 ++++- ui/v2.5/src/components/Wall/WallPanel.tsx | 33 --- ui/v2.5/src/components/Wall/styles.scss | 2 +- 7 files changed, 515 insertions(+), 37 deletions(-) create mode 100644 ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx create mode 100644 ui/v2.5/src/components/Scenes/SceneWallPanel.tsx diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index e77b20298..2492e5599 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -9,7 +9,7 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { Tagger } from "../Tagger/scenes/SceneTagger"; import { IPlaySceneOptions, SceneQueue } from "src/models/sceneQueue"; -import { SceneWallPanel } from "../Wall/WallPanel"; +import { SceneWallPanel } from "./SceneWallPanel"; import { SceneListTable } from "./SceneListTable"; import { EditScenesDialog } from "./EditScenesDialog"; import { DeleteScenesDialog } from "./DeleteScenesDialog"; diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index 33ae79558..f28ac718b 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -12,7 +12,7 @@ import NavUtils from "src/utils/navigation"; import { ItemList, ItemListContext } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; -import { MarkerWallPanel } from "../Wall/WallPanel"; +import { MarkerWallPanel } from "./SceneMarkerWallPanel"; import { View } from "../List/views"; import { SceneMarkerCardsGrid } from "./SceneMarkerCardsGrid"; import { DeleteSceneMarkersDialog } from "./DeleteSceneMarkersDialog"; diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx new file mode 100644 index 000000000..f240b36e6 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx @@ -0,0 +1,234 @@ +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import * as GQL from "src/core/generated-graphql"; +import Gallery, { + GalleryI, + PhotoProps, + RenderImageProps, +} from "react-photo-gallery"; +import { ConfigurationContext } from "src/hooks/Config"; +import { objectTitle } from "src/core/files"; +import { Link, useHistory } from "react-router-dom"; +import { TruncatedText } from "../Shared/TruncatedText"; +import TextUtils from "src/utils/text"; +import cx from "classnames"; +import NavUtils from "src/utils/navigation"; +import { markerTitle } from "src/core/markers"; + +function wallItemTitle(sceneMarker: GQL.SceneMarkerDataFragment) { + const newTitle = markerTitle(sceneMarker); + const seconds = TextUtils.formatTimestampRange( + sceneMarker.seconds, + sceneMarker.end_seconds ?? undefined + ); + if (newTitle) { + return `${newTitle} - ${seconds}`; + } else { + return seconds; + } +} + +interface IMarkerPhoto { + marker: GQL.SceneMarkerDataFragment; + link: string; + onError?: (photo: PhotoProps) => void; +} + +export const MarkerWallItem: React.FC> = ( + props: RenderImageProps +) => { + const { configuration } = useContext(ConfigurationContext); + const playSound = configuration?.interface.soundOnPreview ?? false; + const showTitle = configuration?.interface.wallShowTitle ?? false; + + const [active, setActive] = useState(false); + + type style = Record; + var divStyle: style = { + margin: props.margin, + display: "block", + }; + + if (props.direction === "column") { + divStyle.position = "absolute"; + divStyle.left = props.left; + divStyle.top = props.top; + } + + var handleClick = function handleClick(event: React.MouseEvent) { + if (props.onClick) { + props.onClick(event, { index: props.index }); + } + }; + + const video = props.photo.src.includes("stream"); + const ImagePreview = video ? "video" : "img"; + + const { marker } = props.photo; + const title = wallItemTitle(marker); + const tagNames = marker.tags.map((p) => p.name); + + return ( +
+ setActive(true)} + onMouseLeave={() => setActive(false)} + onClick={handleClick} + onError={() => { + props.photo.onError?.(props.photo); + }} + /> +
+
+ e.stopPropagation()}> + {title && ( + + )} + + +
+
+
+ ); +}; + +interface IMarkerWallProps { + markers: GQL.SceneMarkerDataFragment[]; +} + +// HACK: typescript doesn't allow Gallery to accept a parameter for some reason +const MarkerGallery = Gallery as unknown as GalleryI; + +function getFirstValidSrc(srcSet: string[], invalidSrcSet: string[]) { + if (!srcSet.length) { + return ""; + } + + return ( + srcSet.find((src) => !invalidSrcSet.includes(src)) ?? + ([...srcSet].pop() as string) + ); +} + +interface IFile { + width: number; + height: number; +} + +function getDimensions(file?: IFile) { + const defaults = { width: 1280, height: 720 }; + + if (!file) return defaults; + + return { + width: file.width || defaults.width, + height: file.height || defaults.height, + }; +} + +const defaultTargetRowHeight = 250; + +const MarkerWall: React.FC = ({ markers }) => { + const history = useHistory(); + + const margin = 3; + const direction = "row"; + + const [erroredImgs, setErroredImgs] = useState([]); + + const handleError = useCallback((photo: PhotoProps) => { + setErroredImgs((prev) => [...prev, photo.src]); + }, []); + + useEffect(() => { + setErroredImgs([]); + }, [markers]); + + const photos: PhotoProps[] = useMemo(() => { + return markers.map((m, index) => { + const { width = 1280, height = 720 } = getDimensions(m.scene.files[0]); + + return { + marker: m, + src: getFirstValidSrc([m.stream, m.preview, m.screenshot], erroredImgs), + link: NavUtils.makeSceneMarkerUrl(m), + width, + height, + tabIndex: index, + key: m.id, + loading: "lazy", + alt: objectTitle(m), + onError: handleError, + }; + }); + }, [markers, erroredImgs, handleError]); + + const onClick = useCallback( + (event, { index }) => { + history.push(photos[index].link); + }, + [history, photos] + ); + + function columns(containerWidth: number) { + let preferredSize = 300; + let columnCount = containerWidth / preferredSize; + return Math.round(columnCount); + } + + const renderImage = useCallback((props: RenderImageProps) => { + return ; + }, []); + + return ( +
+ {photos.length ? ( + + ) : null} +
+ ); +}; + +interface IMarkerWallPanelProps { + markers: GQL.SceneMarkerDataFragment[]; +} + +export const MarkerWallPanel: React.FC = ({ + markers, +}) => { + return ; +}; diff --git a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx new file mode 100644 index 000000000..546a1488a --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx @@ -0,0 +1,220 @@ +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import * as GQL from "src/core/generated-graphql"; +import { SceneQueue } from "src/models/sceneQueue"; +import Gallery, { + GalleryI, + PhotoProps, + RenderImageProps, +} from "react-photo-gallery"; +import { ConfigurationContext } from "src/hooks/Config"; +import { objectTitle } from "src/core/files"; +import { Link, useHistory } from "react-router-dom"; +import { TruncatedText } from "../Shared/TruncatedText"; +import TextUtils from "src/utils/text"; +import { useIntl } from "react-intl"; +import cx from "classnames"; + +interface IScenePhoto { + scene: GQL.SlimSceneDataFragment; + link: string; + onError?: (photo: PhotoProps) => void; +} + +export const SceneWallItem: React.FC> = ( + props: RenderImageProps +) => { + const intl = useIntl(); + + const { configuration } = useContext(ConfigurationContext); + const playSound = configuration?.interface.soundOnPreview ?? false; + const showTitle = configuration?.interface.wallShowTitle ?? false; + + const [active, setActive] = useState(false); + + type style = Record; + var divStyle: style = { + margin: props.margin, + display: "block", + }; + + if (props.direction === "column") { + divStyle.position = "absolute"; + divStyle.left = props.left; + divStyle.top = props.top; + } + + var handleClick = function handleClick(event: React.MouseEvent) { + if (props.onClick) { + props.onClick(event, { index: props.index }); + } + }; + + const video = props.photo.src.includes("preview"); + const ImagePreview = video ? "video" : "img"; + + const { scene } = props.photo; + const title = objectTitle(scene); + const performerNames = scene.performers.map((p) => p.name); + const performers = + performerNames.length >= 2 + ? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")] + : performerNames; + + return ( +
+ setActive(true)} + onMouseLeave={() => setActive(false)} + onClick={handleClick} + onError={() => { + props.photo.onError?.(props.photo); + }} + /> +
+
+ e.stopPropagation()}> + {title && ( + + )} + +
{scene.date && TextUtils.formatDate(intl, scene.date)}
+ +
+
+
+ ); +}; + +function getDimensions(s: GQL.SlimSceneDataFragment) { + const defaults = { width: 1280, height: 720 }; + + if (!s.files.length) return defaults; + + return { + width: s.files[0].width || defaults.width, + height: s.files[0].height || defaults.height, + }; +} + +interface ISceneWallProps { + scenes: GQL.SlimSceneDataFragment[]; + sceneQueue?: SceneQueue; +} + +// HACK: typescript doesn't allow Gallery to accept a parameter for some reason +const SceneGallery = Gallery as unknown as GalleryI; + +const defaultTargetRowHeight = 250; + +const SceneWall: React.FC = ({ scenes, sceneQueue }) => { + const history = useHistory(); + + const margin = 3; + const direction = "row"; + + const [erroredImgs, setErroredImgs] = useState([]); + + const handleError = useCallback((photo: PhotoProps) => { + setErroredImgs((prev) => [...prev, photo.src]); + }, []); + + useEffect(() => { + setErroredImgs([]); + }, [scenes]); + + const photos: PhotoProps[] = useMemo(() => { + return scenes.map((s, index) => { + const { width, height } = getDimensions(s); + + return { + scene: s, + src: + s.paths.preview && !erroredImgs.includes(s.paths.preview) + ? s.paths.preview! + : s.paths.screenshot!, + link: sceneQueue + ? sceneQueue.makeLink(s.id, { sceneIndex: index }) + : `/scenes/${s.id}`, + width, + height, + tabIndex: index, + key: s.id, + loading: "lazy", + alt: objectTitle(s), + onError: handleError, + }; + }); + }, [scenes, sceneQueue, erroredImgs, handleError]); + + const onClick = useCallback( + (event, { index }) => { + history.push(photos[index].link); + }, + [history, photos] + ); + + function columns(containerWidth: number) { + let preferredSize = 300; + let columnCount = containerWidth / preferredSize; + return Math.round(columnCount); + } + + const renderImage = useCallback((props: RenderImageProps) => { + return ; + }, []); + + return ( +
+ {photos.length ? ( + + ) : null} +
+ ); +}; + +interface ISceneWallPanelProps { + scenes: GQL.SlimSceneDataFragment[]; + sceneQueue?: SceneQueue; +} + +export const SceneWallPanel: React.FC = ({ + scenes, + sceneQueue, +}) => { + return ; +}; diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index bb50236ec..e7c5af22a 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -561,7 +561,7 @@ input[type="range"].blue-slider { } .scene-markers-panel { - .wall-item { + .wall .wall-item { height: inherit; min-height: 14rem; width: calc(100% - 2rem); @@ -901,3 +901,60 @@ input[type="range"].blue-slider { word-break: break-all; } } + +.scene-wall, +.marker-wall { + .wall-item { + position: relative; + + .lineargradient { + background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.3)); + bottom: 0; + height: 100px; + position: absolute; + width: 100%; + } + + &-title { + font-weight: bold; + } + + &-footer { + bottom: 20px; + padding: 0 1rem; + position: absolute; + text-shadow: 1px 1px 3px black; + transition: 0s opacity; + width: 100%; + z-index: 2; + + @media (min-width: 768px) { + opacity: 0; + } + + &:hover { + .wall-item-title { + text-decoration: underline; + } + } + + a { + color: white; + } + } + + &:hover .wall-item-footer { + opacity: 1; + transition: 1s opacity; + transition-delay: 500ms; + + a { + text-decoration: none; + } + } + + &.show-title .wall-item-footer { + opacity: 1; + } + } +} diff --git a/ui/v2.5/src/components/Wall/WallPanel.tsx b/ui/v2.5/src/components/Wall/WallPanel.tsx index 91ee7607f..04b0b4341 100644 --- a/ui/v2.5/src/components/Wall/WallPanel.tsx +++ b/ui/v2.5/src/components/Wall/WallPanel.tsx @@ -62,18 +62,6 @@ const WallPanel = ({ ); }; -interface IImageWallPanelProps { - images: GQL.SlimImageDataFragment[]; - clickHandler?: (e: MouseEvent, item: GQL.SlimImageDataFragment) => void; -} - -export const ImageWallPanel: React.FC = ({ - images, - clickHandler, -}) => { - return ; -}; - interface IMarkerWallPanelProps { markers: GQL.SceneMarkerDataFragment[]; clickHandler?: (e: MouseEvent, item: GQL.SceneMarkerDataFragment) => void; @@ -87,24 +75,3 @@ export const MarkerWallPanel: React.FC = ({ ); }; - -interface ISceneWallPanelProps { - scenes: GQL.SlimSceneDataFragment[]; - sceneQueue?: SceneQueue; - clickHandler?: (e: MouseEvent, item: GQL.SlimSceneDataFragment) => void; -} - -export const SceneWallPanel: React.FC = ({ - scenes, - sceneQueue, - clickHandler, -}) => { - return ( - - ); -}; diff --git a/ui/v2.5/src/components/Wall/styles.scss b/ui/v2.5/src/components/Wall/styles.scss index da38cb5f0..a75a0ed17 100644 --- a/ui/v2.5/src/components/Wall/styles.scss +++ b/ui/v2.5/src/components/Wall/styles.scss @@ -2,7 +2,7 @@ margin: 0 auto; max-width: 2250px; - &-item { + .wall-item { height: 11.25vw; line-height: 0; max-height: 253px;