thumbnails

This commit is contained in:
frost768 2023-06-08 04:08:17 +03:00
parent 49106c1254
commit 1ade111757
12 changed files with 208 additions and 54 deletions

View File

@ -1,10 +1,8 @@
import React, { RefObject, useCallback, useEffect, useState } from "react";
type ActivityEvent =
| React.MouseEvent<HTMLElement>
| React.TouchEvent<HTMLElement>
| MouseEvent
| TouchEvent;
export type MouseActivity = React.MouseEvent<HTMLElement> | MouseEvent;
type ActivityEvent = MouseActivity | React.TouchEvent<HTMLElement> | TouchEvent;
export function makePercentageString(num: number) {
return `${num.toFixed(2)}%`;

View File

@ -4,7 +4,8 @@ import * as Sentry from "@sentry/react";
import { conf } from "@/setup/config";
import { SENTRY_DSN } from "@/setup/constants";
Sentry.init({
if (process.env.NODE_ENV !== "development")
Sentry.init({
dsn: SENTRY_DSN,
release: `movie-web@${conf().APP_VERSION}`,
sampleRate: 0.5,
@ -13,4 +14,4 @@ Sentry.init({
new CaptureConsole(),
new HttpClient(),
],
});
});

View File

@ -0,0 +1,21 @@
export function formatSeconds(secs: number, showHours = false): string {
if (Number.isNaN(secs)) {
if (showHours) return "0:00:00";
return "0:00";
}
let time = secs;
const seconds = Math.floor(time % 60);
time /= 60;
const minutes = Math.floor(time % 60);
time /= 60;
const hours = Math.floor(time);
const paddedSecs = seconds.toString().padStart(2, "0");
const paddedMins = minutes.toString().padStart(2, "0");
if (!showHours) return [paddedMins, paddedSecs].join(":");
return [hours, paddedMins, paddedSecs].join(":");
}

View File

@ -0,0 +1,52 @@
export interface Thumbnail {
from: number;
to: number;
imgUrl: string;
}
export const SCALE_FACTOR = 0.1;
export default async function* extractThumbnails(
videoUrl: string,
numThumbnails: number
): AsyncGenerator<Thumbnail, Thumbnail> {
const video = document.createElement("video");
video.src = videoUrl;
video.crossOrigin = "anonymous";
// Wait for the video metadata to load
const metadata = await new Promise((resolve, reject) => {
video.addEventListener("loadedmetadata", resolve);
video.addEventListener("error", reject);
});
const canvas = document.createElement("canvas");
canvas.height = video.videoHeight * SCALE_FACTOR;
canvas.width = video.videoWidth * SCALE_FACTOR;
const ctx = canvas.getContext("2d");
if (!ctx) return { from: 0, to: 0, imgUrl: "" };
for (let i = 0; i <= numThumbnails; i += 1) {
const from = (i / (numThumbnails + 1)) * video.duration;
const to = ((i + 1) / (numThumbnails + 1)) * video.duration;
// Seek to the specified time
video.currentTime = from;
await new Promise((resolve) => {
video.addEventListener("seeked", resolve);
});
// Draw the video frame on the canvas
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// Convert the canvas to a data URL and add it to the list of thumbnails
const imgUrl = canvas.toDataURL();
yield {
from,
to,
imgUrl,
};
}
return { from: 0, to: 0, imgUrl: "" };
}

View File

@ -22,7 +22,10 @@ export function BackdropAction(props: BackdropActionProps) {
const lastTouchEnd = useRef<number>(0);
const handleMouseMove = useCallback(() => {
const handleMouseMove = useCallback(
(e) => {
// to enable thumbnail on mouse hover
e.stopPropagation();
if (!moved) {
setTimeout(() => {
// If NOT a touch, set moved to true
@ -37,7 +40,9 @@ export function BackdropAction(props: BackdropActionProps) {
setMoved(false);
timeout.current = null;
}, 3000);
}, [setMoved, moved]);
},
[setMoved, moved]
);
const handleMouseLeave = useCallback(() => {
setMoved(false);

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
MouseActivity,
makePercentage,
makePercentageString,
useProgressBar,
@ -10,6 +11,8 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useProgress } from "@/video/state/logic/progress";
import ThumbnailAction from "./ThumbnailAction";
export function ProgressAction() {
const descriptor = useVideoPlayerDescriptor();
const controls = useControls(descriptor);
@ -17,7 +20,15 @@ export function ProgressAction() {
const ref = useRef<HTMLDivElement>(null);
const dragRef = useRef<boolean>(false);
const controlRef = useRef<typeof controls>(controls);
const [hoverPosition, setHoverPosition] = useState<number>(0);
const [isThumbnailVisible, setIsThumbnailVisible] = useState<boolean>(false);
const onMouseOver = useCallback((e: MouseActivity) => {
setHoverPosition(e.clientX);
setIsThumbnailVisible(true);
}, []);
const onMouseLeave = useCallback(() => {
setIsThumbnailVisible(false);
}, []);
useEffect(() => {
controlRef.current = controls;
}, [controls]);
@ -65,6 +76,8 @@ export function ProgressAction() {
className="-my-3 flex h-8 items-center"
onMouseDown={dragMouseDown}
onTouchStart={dragMouseDown}
onMouseMove={onMouseOver}
onMouseLeave={onMouseLeave}
>
<div
className={`relative h-1 flex-1 rounded-full bg-gray-500 bg-opacity-50 transition-[height] duration-100 group-hover:h-2 ${
@ -88,6 +101,13 @@ export function ProgressAction() {
dragging ? "!scale-[400%] !opacity-100" : ""
}`}
/>
{isThumbnailVisible ? (
<ThumbnailAction
parentRef={ref}
videoTime={videoTime}
hoverPosition={hoverPosition}
/>
) : null}
</div>
</div>
</div>

View File

@ -0,0 +1,62 @@
import { RefObject } from "react";
import { formatSeconds } from "@/utils/formatSeconds";
import { SCALE_FACTOR } from "@/utils/thumbnailCreator";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { VideoProgressEvent } from "@/video/state/logic/progress";
import { useSource } from "@/video/state/logic/source";
export default function ThumbnailAction({
parentRef,
hoverPosition,
videoTime,
}: {
parentRef: RefObject<HTMLDivElement>;
hoverPosition: number;
videoTime: VideoProgressEvent;
}) {
const descriptor = useVideoPlayerDescriptor();
const source = useSource(descriptor);
if (!parentRef.current) return null;
const offset =
(document.getElementsByTagName("video")[0].videoWidth * SCALE_FACTOR) / 2;
const rect = parentRef.current.getBoundingClientRect();
const hoverPercent = (hoverPosition - rect.left) / rect.width;
const hoverTime = videoTime.duration * hoverPercent;
const pos = () => {
const relativePosition = hoverPosition - rect.left;
if (relativePosition <= offset) {
return 0;
}
if (relativePosition >= rect.width - offset) {
return rect.width - offset * 2;
}
return relativePosition - offset;
};
return (
<div>
<img
style={{
left: `${pos()}px`,
}}
className="absolute bottom-10 rounded"
src={
source.source?.thumbnails.find(
(x) => x.from < hoverTime && x.to > hoverTime
)?.imgUrl
}
/>
<div
style={{
left: `${pos() + offset - 18}px`,
}}
className="absolute bottom-3 text-white"
>
{formatSeconds(hoverTime)}
</div>
</div>
);
}

View File

@ -1,6 +1,7 @@
import { useTranslation } from "react-i18next";
import { useIsMobile } from "@/hooks/useIsMobile";
import { formatSeconds } from "@/utils/formatSeconds";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useInterface } from "@/video/state/logic/interface";
@ -12,28 +13,6 @@ function durationExceedsHour(secs: number): boolean {
return secs > 60 * 60;
}
function formatSeconds(secs: number, showHours = false): string {
if (Number.isNaN(secs)) {
if (showHours) return "0:00:00";
return "0:00";
}
let time = secs;
const seconds = Math.floor(time % 60);
time /= 60;
const minutes = Math.floor(time % 60);
time /= 60;
const hours = Math.floor(time);
const paddedSecs = seconds.toString().padStart(2, "0");
const paddedMins = minutes.toString().padStart(2, "0");
if (!showHours) return [paddedMins, paddedSecs].join(":");
return [hours, paddedMins, paddedSecs].join(":");
}
interface Props {
className?: string;
noDuration?: boolean;

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
import { Thumbnail } from "@/utils/thumbnailCreator";
import { getPlayerState } from "../cache";
import { listenEvent, sendEvent, unlistenEvent } from "../events";
@ -17,6 +18,7 @@ export type VideoSourceEvent = {
id: string;
url: string;
};
thumbnails: Thumbnail[];
};
};

View File

@ -154,6 +154,7 @@ export function createCastingStateProvider(
caption: null,
embedId: source.embedId,
providerId: source.providerId,
thumbnails: [],
};
resetStateForSource(descriptor, state);
updateSource(descriptor, state);

View File

@ -11,6 +11,7 @@ import {
canWebkitFullscreen,
canWebkitPictureInPicture,
} from "@/utils/detectFeatures";
import extractThumbnails from "@/utils/thumbnailCreator";
import {
getStoredVolume,
setStoredVolume,
@ -193,7 +194,17 @@ export function createVideoStateProvider(
caption: null,
embedId: source.embedId,
providerId: source.providerId,
thumbnails: [],
};
(async () => {
for await (const thumbnail of extractThumbnails(source.source, 20)) {
if (!state.source) return;
state.source.thumbnails = [...state.source.thumbnails, thumbnail];
updateSource(descriptor, state);
}
})();
updateSource(descriptor, state);
},
setCaption(id, url) {

View File

@ -6,6 +6,7 @@ import {
MWStreamType,
} from "@/backend/helpers/streams";
import { DetailedMeta } from "@/backend/metadata/getmeta";
import { Thumbnail } from "@/utils/thumbnailCreator";
import { VideoPlayerStateProvider } from "./providers/providerTypes";
@ -75,6 +76,7 @@ export type VideoPlayerState = {
url: string;
id: string;
};
thumbnails: Thumbnail[];
};
// casting state