mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-21 14:47:41 +01:00
thumbnails
This commit is contained in:
parent
49106c1254
commit
1ade111757
12 changed files with 208 additions and 54 deletions
|
@ -1,10 +1,8 @@
|
||||||
import React, { RefObject, useCallback, useEffect, useState } from "react";
|
import React, { RefObject, useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
type ActivityEvent =
|
export type MouseActivity = React.MouseEvent<HTMLElement> | MouseEvent;
|
||||||
| React.MouseEvent<HTMLElement>
|
|
||||||
| React.TouchEvent<HTMLElement>
|
type ActivityEvent = MouseActivity | React.TouchEvent<HTMLElement> | TouchEvent;
|
||||||
| MouseEvent
|
|
||||||
| TouchEvent;
|
|
||||||
|
|
||||||
export function makePercentageString(num: number) {
|
export function makePercentageString(num: number) {
|
||||||
return `${num.toFixed(2)}%`;
|
return `${num.toFixed(2)}%`;
|
||||||
|
|
|
@ -4,7 +4,8 @@ import * as Sentry from "@sentry/react";
|
||||||
import { conf } from "@/setup/config";
|
import { conf } from "@/setup/config";
|
||||||
import { SENTRY_DSN } from "@/setup/constants";
|
import { SENTRY_DSN } from "@/setup/constants";
|
||||||
|
|
||||||
Sentry.init({
|
if (process.env.NODE_ENV !== "development")
|
||||||
|
Sentry.init({
|
||||||
dsn: SENTRY_DSN,
|
dsn: SENTRY_DSN,
|
||||||
release: `movie-web@${conf().APP_VERSION}`,
|
release: `movie-web@${conf().APP_VERSION}`,
|
||||||
sampleRate: 0.5,
|
sampleRate: 0.5,
|
||||||
|
@ -13,4 +14,4 @@ Sentry.init({
|
||||||
new CaptureConsole(),
|
new CaptureConsole(),
|
||||||
new HttpClient(),
|
new HttpClient(),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
21
src/utils/formatSeconds.ts
Normal file
21
src/utils/formatSeconds.ts
Normal 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(":");
|
||||||
|
}
|
52
src/utils/thumbnailCreator.ts
Normal file
52
src/utils/thumbnailCreator.ts
Normal 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: "" };
|
||||||
|
}
|
|
@ -22,7 +22,10 @@ export function BackdropAction(props: BackdropActionProps) {
|
||||||
|
|
||||||
const lastTouchEnd = useRef<number>(0);
|
const lastTouchEnd = useRef<number>(0);
|
||||||
|
|
||||||
const handleMouseMove = useCallback(() => {
|
const handleMouseMove = useCallback(
|
||||||
|
(e) => {
|
||||||
|
// to enable thumbnail on mouse hover
|
||||||
|
e.stopPropagation();
|
||||||
if (!moved) {
|
if (!moved) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// If NOT a touch, set moved to true
|
// If NOT a touch, set moved to true
|
||||||
|
@ -37,7 +40,9 @@ export function BackdropAction(props: BackdropActionProps) {
|
||||||
setMoved(false);
|
setMoved(false);
|
||||||
timeout.current = null;
|
timeout.current = null;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}, [setMoved, moved]);
|
},
|
||||||
|
[setMoved, moved]
|
||||||
|
);
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
const handleMouseLeave = useCallback(() => {
|
||||||
setMoved(false);
|
setMoved(false);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
MouseActivity,
|
||||||
makePercentage,
|
makePercentage,
|
||||||
makePercentageString,
|
makePercentageString,
|
||||||
useProgressBar,
|
useProgressBar,
|
||||||
|
@ -10,6 +11,8 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { useProgress } from "@/video/state/logic/progress";
|
import { useProgress } from "@/video/state/logic/progress";
|
||||||
|
|
||||||
|
import ThumbnailAction from "./ThumbnailAction";
|
||||||
|
|
||||||
export function ProgressAction() {
|
export function ProgressAction() {
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
const descriptor = useVideoPlayerDescriptor();
|
||||||
const controls = useControls(descriptor);
|
const controls = useControls(descriptor);
|
||||||
|
@ -17,7 +20,15 @@ export function ProgressAction() {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const dragRef = useRef<boolean>(false);
|
const dragRef = useRef<boolean>(false);
|
||||||
const controlRef = useRef<typeof controls>(controls);
|
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(() => {
|
useEffect(() => {
|
||||||
controlRef.current = controls;
|
controlRef.current = controls;
|
||||||
}, [controls]);
|
}, [controls]);
|
||||||
|
@ -65,6 +76,8 @@ export function ProgressAction() {
|
||||||
className="-my-3 flex h-8 items-center"
|
className="-my-3 flex h-8 items-center"
|
||||||
onMouseDown={dragMouseDown}
|
onMouseDown={dragMouseDown}
|
||||||
onTouchStart={dragMouseDown}
|
onTouchStart={dragMouseDown}
|
||||||
|
onMouseMove={onMouseOver}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`relative h-1 flex-1 rounded-full bg-gray-500 bg-opacity-50 transition-[height] duration-100 group-hover:h-2 ${
|
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" : ""
|
dragging ? "!scale-[400%] !opacity-100" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
{isThumbnailVisible ? (
|
||||||
|
<ThumbnailAction
|
||||||
|
parentRef={ref}
|
||||||
|
videoTime={videoTime}
|
||||||
|
hoverPosition={hoverPosition}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
62
src/video/components/actions/ThumbnailAction.tsx
Normal file
62
src/video/components/actions/ThumbnailAction.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
|
import { formatSeconds } from "@/utils/formatSeconds";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { useInterface } from "@/video/state/logic/interface";
|
import { useInterface } from "@/video/state/logic/interface";
|
||||||
|
@ -12,28 +13,6 @@ function durationExceedsHour(secs: number): boolean {
|
||||||
return secs > 60 * 60;
|
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 {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
noDuration?: boolean;
|
noDuration?: boolean;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||||
|
import { Thumbnail } from "@/utils/thumbnailCreator";
|
||||||
|
|
||||||
import { getPlayerState } from "../cache";
|
import { getPlayerState } from "../cache";
|
||||||
import { listenEvent, sendEvent, unlistenEvent } from "../events";
|
import { listenEvent, sendEvent, unlistenEvent } from "../events";
|
||||||
|
@ -17,6 +18,7 @@ export type VideoSourceEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
thumbnails: Thumbnail[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -154,6 +154,7 @@ export function createCastingStateProvider(
|
||||||
caption: null,
|
caption: null,
|
||||||
embedId: source.embedId,
|
embedId: source.embedId,
|
||||||
providerId: source.providerId,
|
providerId: source.providerId,
|
||||||
|
thumbnails: [],
|
||||||
};
|
};
|
||||||
resetStateForSource(descriptor, state);
|
resetStateForSource(descriptor, state);
|
||||||
updateSource(descriptor, state);
|
updateSource(descriptor, state);
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
canWebkitFullscreen,
|
canWebkitFullscreen,
|
||||||
canWebkitPictureInPicture,
|
canWebkitPictureInPicture,
|
||||||
} from "@/utils/detectFeatures";
|
} from "@/utils/detectFeatures";
|
||||||
|
import extractThumbnails from "@/utils/thumbnailCreator";
|
||||||
import {
|
import {
|
||||||
getStoredVolume,
|
getStoredVolume,
|
||||||
setStoredVolume,
|
setStoredVolume,
|
||||||
|
@ -193,7 +194,17 @@ export function createVideoStateProvider(
|
||||||
caption: null,
|
caption: null,
|
||||||
embedId: source.embedId,
|
embedId: source.embedId,
|
||||||
providerId: source.providerId,
|
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);
|
updateSource(descriptor, state);
|
||||||
},
|
},
|
||||||
setCaption(id, url) {
|
setCaption(id, url) {
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
MWStreamType,
|
MWStreamType,
|
||||||
} from "@/backend/helpers/streams";
|
} from "@/backend/helpers/streams";
|
||||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||||
|
import { Thumbnail } from "@/utils/thumbnailCreator";
|
||||||
|
|
||||||
import { VideoPlayerStateProvider } from "./providers/providerTypes";
|
import { VideoPlayerStateProvider } from "./providers/providerTypes";
|
||||||
|
|
||||||
|
@ -75,6 +76,7 @@ export type VideoPlayerState = {
|
||||||
url: string;
|
url: string;
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
thumbnails: Thumbnail[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// casting state
|
// casting state
|
||||||
|
|
Loading…
Reference in a new issue