From 2eb8284b4b452cc39b4c48bcf4cfb22c55ed348d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 24 Aug 2023 11:14:20 +1000 Subject: [PATCH] Preview scrubber (#4022) * Add sprite info hook * Remove axios dependency * Add preview scrubber * Add scrubber timestamp * On click go to timestamp --- ui/v2.5/package.json | 1 - .../ScenePlayer/ScenePlayerScrubber.tsx | 85 +++------ .../src/components/Scenes/PreviewScrubber.tsx | 173 ++++++++++++++++++ ui/v2.5/src/components/Scenes/SceneCard.tsx | 23 ++- ui/v2.5/src/components/Scenes/styles.scss | 63 +++++++ ui/v2.5/src/hooks/sprite.ts | 62 +++++++ ui/v2.5/src/models/sceneQueue.ts | 4 + 7 files changed, 347 insertions(+), 64 deletions(-) create mode 100644 ui/v2.5/src/components/Scenes/PreviewScrubber.tsx create mode 100644 ui/v2.5/src/hooks/sprite.ts diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index 60b2d35f477..98ec62dea30 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -32,7 +32,6 @@ "@silvermine/videojs-airplay": "^1.2.0", "@silvermine/videojs-chromecast": "^1.4.1", "apollo-upload-client": "^17.0.0", - "axios": "^1.3.3", "base64-blob": "^1.4.1", "bootstrap": "^4.6.2", "classnames": "^2.3.2", diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx index 68dfbb406da..c6cf120fba1 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx @@ -6,15 +6,14 @@ import React, { useCallback, } from "react"; import { Button } from "react-bootstrap"; -import axios from "axios"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; -import { WebVTT } from "videojs-vtt.js"; import { Icon } from "src/components/Shared/Icon"; import { faChevronRight, faChevronLeft, } from "@fortawesome/free-solid-svg-icons"; +import { useSpriteInfo } from "src/hooks/sprite"; interface IScenePlayerScrubberProps { file: GQL.VideoFileDataFragment; @@ -29,42 +28,6 @@ interface ISceneSpriteItem { time: string; } -interface ISceneSpriteInfo { - url: string; - start: number; - end: number; - x: number; - y: number; - w: number; - h: number; -} - -async function fetchSpriteInfo(vttPath: string) { - const response = await axios.get(vttPath, { responseType: "text" }); - - const sprites: ISceneSpriteInfo[] = []; - - const parser = new WebVTT.Parser(window, WebVTT.StringDecoder()); - parser.oncue = (cue: VTTCue) => { - const match = cue.text.match(/^([^#]*)#xywh=(\d+),(\d+),(\d+),(\d+)$/i); - if (!match) return; - - sprites.push({ - url: new URL(match[1], vttPath).href, - start: cue.startTime, - end: cue.endTime, - x: Number(match[2]), - y: Number(match[3]), - w: Number(match[4]), - h: Number(match[5]), - }); - }; - parser.parse(response.data); - parser.flush(); - - return sprites; -} - export const ScenePlayerScrubber: React.FC = ({ file, scene, @@ -119,34 +82,32 @@ export const ScenePlayerScrubber: React.FC = ({ [onSeek, file.duration, scrubWidth] ); + const spriteInfo = useSpriteInfo(scene.paths.vtt ?? undefined); const [spriteItems, setSpriteItems] = useState(); useEffect(() => { - if (!scene.paths.vtt) return; - fetchSpriteInfo(scene.paths.vtt).then((sprites) => { - if (!sprites) return; - let totalWidth = 0; - const newSprites = sprites?.map((sprite, index) => { - totalWidth += sprite.w; - const left = sprite.w * index; - const style = { - width: `${sprite.w}px`, - height: `${sprite.h}px`, - backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, - backgroundImage: `url(${sprite.url})`, - left: `${left}px`, - }; - const start = TextUtils.secondsToTimestamp(sprite.start); - const end = TextUtils.secondsToTimestamp(sprite.end); - return { - style, - time: `${start} - ${end}`, - }; - }); - setScrubWidth(totalWidth); - setSpriteItems(newSprites); + if (!spriteInfo) return; + let totalWidth = 0; + const newSprites = spriteInfo?.map((sprite, index) => { + totalWidth += sprite.w; + const left = sprite.w * index; + const style = { + width: `${sprite.w}px`, + height: `${sprite.h}px`, + backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, + backgroundImage: `url(${sprite.url})`, + left: `${left}px`, + }; + const start = TextUtils.secondsToTimestamp(sprite.start); + const end = TextUtils.secondsToTimestamp(sprite.end); + return { + style, + time: `${start} - ${end}`, + }; }); - }, [scene]); + setScrubWidth(totalWidth); + setSpriteItems(newSprites); + }, [spriteInfo]); useEffect(() => { const onResize = (entries: ResizeObserverEntry[]) => { diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx new file mode 100644 index 00000000000..52be78c7b67 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -0,0 +1,173 @@ +import React, { useMemo } from "react"; +import { useDebounce } from "src/hooks/debounce"; +import { useSpriteInfo } from "src/hooks/sprite"; +import TextUtils from "src/utils/text"; + +interface IHoverScrubber { + totalSprites: number; + activeIndex: number | undefined; + setActiveIndex: (index: number | undefined) => void; + onClick?: (index: number) => void; +} + +const HoverScrubber: React.FC = ({ + totalSprites, + activeIndex, + setActiveIndex, + onClick, +}) => { + function getActiveIndex(e: React.MouseEvent) { + const { width } = e.currentTarget.getBoundingClientRect(); + const x = e.nativeEvent.offsetX; + + return Math.floor((x / width) * totalSprites); + } + + function onMouseMove(e: React.MouseEvent) { + const relatedTarget = e.currentTarget; + + if (relatedTarget !== e.target) return; + + setActiveIndex(getActiveIndex(e)); + } + + function onMouseLeave() { + setActiveIndex(undefined); + } + + function onScrubberClick(e: React.MouseEvent) { + if (!onClick) return; + + const relatedTarget = e.currentTarget; + + if (relatedTarget !== e.target) return; + + e.preventDefault(); + onClick(getActiveIndex(e)); + } + + const indicatorStyle = useMemo(() => { + if (activeIndex === undefined) return {}; + + const width = (activeIndex / totalSprites) * 100; + + return { + width: `${width}%`, + }; + }, [activeIndex, totalSprites]); + + return ( +
+
+
+ {activeIndex !== undefined && ( +
+ )} +
+
+ ); +}; + +interface IScenePreviewProps { + vttPath: string | undefined; + onClick?: (timestamp: number) => void; +} + +function scaleToFit(dimensions: { w: number; h: number }, bounds: DOMRect) { + const rw = bounds.width / dimensions.w; + const rh = bounds.height / dimensions.h; + + // for consistency, use max by default and min for portrait + if (dimensions.w > dimensions.h) { + return Math.max(rw, rh); + } + + return Math.min(rw, rh); +} + +export const PreviewScrubber: React.FC = ({ + vttPath, + onClick, +}) => { + const imageParentRef = React.useRef(null); + + const [activeIndex, setActiveIndex] = React.useState(); + + const debounceSetActiveIndex = useDebounce( + setActiveIndex, + [setActiveIndex], + 10 + ); + + const spriteInfo = useSpriteInfo(vttPath); + + const style = useMemo(() => { + if (!spriteInfo || activeIndex === undefined || !imageParentRef.current) { + return {}; + } + + const sprite = spriteInfo[activeIndex]; + + const clientRect = imageParentRef.current?.getBoundingClientRect(); + const scale = clientRect ? scaleToFit(sprite, clientRect) : 1; + + return { + backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, + backgroundImage: `url(${sprite.url})`, + width: `${sprite.w}px`, + height: `${sprite.h}px`, + transform: `scale(${scale})`, + }; + }, [spriteInfo, activeIndex, imageParentRef]); + + const currentTime = useMemo(() => { + if (!spriteInfo || activeIndex === undefined) { + return undefined; + } + + const sprite = spriteInfo[activeIndex]; + + const start = TextUtils.secondsToTimestamp(sprite.start); + + return start; + }, [activeIndex, spriteInfo]); + + function onScrubberClick(index: number) { + if (!spriteInfo || !onClick) { + return; + } + + const sprite = spriteInfo[index]; + + onClick(sprite.start); + } + + if (!spriteInfo) return null; + + return ( +
+ {activeIndex !== undefined && spriteInfo && ( +
+
+ {currentTime !== undefined && ( +
{currentTime}
+ )} +
+ )} + debounceSetActiveIndex(i)} + onClick={onScrubberClick} + /> +
+ ); +}; diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index 190c4b4697f..b01cf698faf 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useRef } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; -import { Link } from "react-router-dom"; +import { Link, useHistory } from "react-router-dom"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; @@ -25,12 +25,15 @@ import { faTag, } from "@fortawesome/free-solid-svg-icons"; import { objectPath, objectTitle } from "src/core/files"; +import { PreviewScrubber } from "./PreviewScrubber"; interface IScenePreviewProps { isPortrait: boolean; image?: string; video?: string; soundActive: boolean; + vttPath?: string; + onScrubberClick?: (timestamp: number) => void; } export const ScenePreview: React.FC = ({ @@ -38,6 +41,8 @@ export const ScenePreview: React.FC = ({ video, isPortrait, soundActive, + vttPath, + onScrubberClick, }) => { const videoEl = useRef(null); @@ -72,6 +77,7 @@ export const ScenePreview: React.FC = ({ ref={videoEl} src={video} /> +
); }; @@ -90,6 +96,7 @@ interface ISceneCardProps { export const SceneCard: React.FC = ( props: ISceneCardProps ) => { + const history = useHistory(); const { configuration } = React.useContext(ConfigurationContext); const file = useMemo( @@ -383,6 +390,18 @@ export const SceneCard: React.FC = ( }) : `/scenes/${props.scene.id}`; + 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); + } + return ( = ( video={props.scene.paths.preview ?? undefined} isPortrait={isPortrait()} soundActive={configuration?.interface?.soundOnPreview ?? false} + vttPath={props.scene.paths.vtt ?? undefined} + onScrubberClick={onScrubberClick} /> {maybeRenderSceneSpecsOverlay()} diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 31e5de8d1fc..a2c74cb8895 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -643,3 +643,66 @@ input[type="range"].blue-slider { .scrape-dialog .rating-number.disabled { padding-left: 0.5em; } + +.preview-scrubber { + height: 100%; + position: absolute; + width: 100%; + + .scene-card-preview-image { + align-items: center; + display: flex; + justify-content: center; + overflow: hidden; + } + + .scrubber-image { + height: 100%; + width: 100%; + } + + .scrubber-timestamp { + bottom: calc(20px + 0.25rem); + font-weight: 400; + opacity: 0.75; + position: absolute; + right: 0.7rem; + text-shadow: 0 0 3px #000; + } +} + +.hover-scrubber { + bottom: 0; + height: 20px; + overflow: hidden; + position: absolute; + width: 100%; + + .hover-scrubber-area { + cursor: col-resize; + height: 100%; + position: absolute; + width: 100%; + z-index: 1; + } + + .hover-scrubber-indicator { + background-color: rgba(255, 255, 255, 0.1); + bottom: -100%; + height: 100%; + position: absolute; + transition: bottom 0.2s ease-in-out; + width: 100%; + + .hover-scrubber-indicator-marker { + background-color: rgba(255, 0, 0, 0.5); + bottom: 0; + height: 5px; + position: absolute; + } + } + + &:hover .hover-scrubber-indicator { + bottom: 0; + } +} diff --git a/ui/v2.5/src/hooks/sprite.ts b/ui/v2.5/src/hooks/sprite.ts new file mode 100644 index 00000000000..8d66c2fa568 --- /dev/null +++ b/ui/v2.5/src/hooks/sprite.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; +import { WebVTT } from "videojs-vtt.js"; + +export interface ISceneSpriteInfo { + url: string; + start: number; + end: number; + x: number; + y: number; + w: number; + h: number; +} + +function getSpriteInfo(vttPath: string, response: string) { + const sprites: ISceneSpriteInfo[] = []; + + const parser = new WebVTT.Parser(window, WebVTT.StringDecoder()); + parser.oncue = (cue: VTTCue) => { + const match = cue.text.match(/^([^#]*)#xywh=(\d+),(\d+),(\d+),(\d+)$/i); + if (!match) return; + + sprites.push({ + url: new URL(match[1], vttPath).href, + start: cue.startTime, + end: cue.endTime, + x: Number(match[2]), + y: Number(match[3]), + w: Number(match[4]), + h: Number(match[5]), + }); + }; + parser.parse(response); + parser.flush(); + + return sprites; +} + +export function useSpriteInfo(vttPath: string | undefined) { + const [spriteInfo, setSpriteInfo] = useState< + ISceneSpriteInfo[] | undefined + >(); + + useEffect(() => { + if (!vttPath) { + setSpriteInfo(undefined); + return; + } + + fetch(vttPath).then((response) => { + if (!response.ok) { + setSpriteInfo(undefined); + return; + } + + response.text().then((text) => { + setSpriteInfo(getSpriteInfo(vttPath, text)); + }); + }); + }, [vttPath]); + + return spriteInfo; +} diff --git a/ui/v2.5/src/models/sceneQueue.ts b/ui/v2.5/src/models/sceneQueue.ts index de9cf2bbee2..14f81df5925 100644 --- a/ui/v2.5/src/models/sceneQueue.ts +++ b/ui/v2.5/src/models/sceneQueue.ts @@ -9,6 +9,7 @@ export interface IPlaySceneOptions { newPage?: number; autoPlay?: boolean; continue?: boolean; + start?: number; } export class SceneQueue { @@ -117,6 +118,9 @@ export class SceneQueue { if (options.continue !== undefined) { params.push("continue=" + options.continue); } + if (options.start !== undefined) { + params.push("t=" + options.start); + } return `/scenes/${sceneID}${params.length ? "?" + params.join("&") : ""}`; } }