Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Preview scrubber #4022

Merged
merged 7 commits into from
Aug 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading