Skip to content

Commit

Permalink
Preview scrubber (stashapp#4022)
Browse files Browse the repository at this point in the history
* Add sprite info hook
* Remove axios dependency
* Add preview scrubber
* Add scrubber timestamp
* On click go to timestamp
  • Loading branch information
WithoutPants authored and halkeye committed Sep 1, 2024
1 parent d812ccf commit 2eb8284
Show file tree
Hide file tree
Showing 7 changed files with 347 additions and 64 deletions.
1 change: 0 additions & 1 deletion ui/v2.5/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
85 changes: 23 additions & 62 deletions ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string>(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<IScenePlayerScrubberProps> = ({
file,
scene,
Expand Down Expand Up @@ -119,34 +82,32 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = ({
[onSeek, file.duration, scrubWidth]
);

const spriteInfo = useSpriteInfo(scene.paths.vtt ?? undefined);
const [spriteItems, setSpriteItems] = useState<ISceneSpriteItem[]>();

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[]) => {
Expand Down
173 changes: 173 additions & 0 deletions ui/v2.5/src/components/Scenes/PreviewScrubber.tsx
Original file line number Diff line number Diff line change
@@ -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<IHoverScrubber> = ({
totalSprites,
activeIndex,
setActiveIndex,
onClick,
}) => {
function getActiveIndex(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
const { width } = e.currentTarget.getBoundingClientRect();
const x = e.nativeEvent.offsetX;

return Math.floor((x / width) * totalSprites);
}

function onMouseMove(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
const relatedTarget = e.currentTarget;

if (relatedTarget !== e.target) return;

setActiveIndex(getActiveIndex(e));
}

function onMouseLeave() {
setActiveIndex(undefined);
}

function onScrubberClick(e: React.MouseEvent<HTMLDivElement, 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 (
<div className="hover-scrubber">
<div
className="hover-scrubber-area"
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
onClick={onScrubberClick}
/>
<div className="hover-scrubber-indicator">
{activeIndex !== undefined && (
<div
className="hover-scrubber-indicator-marker"
style={indicatorStyle}
></div>
)}
</div>
</div>
);
};

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<IScenePreviewProps> = ({
vttPath,
onClick,
}) => {
const imageParentRef = React.useRef<HTMLDivElement>(null);

const [activeIndex, setActiveIndex] = React.useState<number | undefined>();

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 (
<div className="preview-scrubber">
{activeIndex !== undefined && spriteInfo && (
<div className="scene-card-preview-image" ref={imageParentRef}>
<div className="scrubber-image" style={style}></div>
{currentTime !== undefined && (
<div className="scrubber-timestamp">{currentTime}</div>
)}
</div>
)}
<HoverScrubber
totalSprites={81}
activeIndex={activeIndex}
setActiveIndex={(i) => debounceSetActiveIndex(i)}
onClick={onScrubberClick}
/>
</div>
);
};
23 changes: 22 additions & 1 deletion ui/v2.5/src/components/Scenes/SceneCard.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,19 +25,24 @@ 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<IScenePreviewProps> = ({
image,
video,
isPortrait,
soundActive,
vttPath,
onScrubberClick,
}) => {
const videoEl = useRef<HTMLVideoElement>(null);

Expand Down Expand Up @@ -72,6 +77,7 @@ export const ScenePreview: React.FC<IScenePreviewProps> = ({
ref={videoEl}
src={video}
/>
<PreviewScrubber vttPath={vttPath} onClick={onScrubberClick} />
</div>
);
};
Expand All @@ -90,6 +96,7 @@ interface ISceneCardProps {
export const SceneCard: React.FC<ISceneCardProps> = (
props: ISceneCardProps
) => {
const history = useHistory();
const { configuration } = React.useContext(ConfigurationContext);

const file = useMemo(
Expand Down Expand Up @@ -383,6 +390,18 @@ export const SceneCard: React.FC<ISceneCardProps> = (
})
: `/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 (
<GridCard
className={`scene-card ${zoomIndex()} ${filelessClass()}`}
Expand All @@ -404,6 +423,8 @@ export const SceneCard: React.FC<ISceneCardProps> = (
video={props.scene.paths.preview ?? undefined}
isPortrait={isPortrait()}
soundActive={configuration?.interface?.soundOnPreview ?? false}
vttPath={props.scene.paths.vtt ?? undefined}
onScrubberClick={onScrubberClick}
/>
<RatingBanner rating={props.scene.rating100} />
{maybeRenderSceneSpecsOverlay()}
Expand Down
Loading

0 comments on commit 2eb8284

Please sign in to comment.