From e7c0e022f73168c606404b3e72b59cb3c0357fa9 Mon Sep 17 00:00:00 2001 From: frost768 Date: Mon, 1 May 2023 11:33:25 +0300 Subject: [PATCH 1/7] linting --- src/components/Dropdown.tsx | 2 +- src/components/SearchBar.tsx | 4 ++-- src/components/layout/Backdrop.tsx | 2 +- src/components/layout/Navigation.tsx | 2 +- src/components/media/EpisodeButton.tsx | 4 ++-- src/components/media/MediaCard.tsx | 2 +- src/components/popout/FloatingCard.tsx | 2 +- src/video/components/VideoPlayer.tsx | 2 +- src/video/components/actions/VolumeAdjustedAction.tsx | 2 +- .../actions/list-entries/QualityDisplayAction.tsx | 2 +- src/video/components/parts/VideoErrorBoundary.tsx | 2 +- src/video/components/parts/VideoPlayerError.tsx | 2 +- src/views/developer/VideoTesterView.tsx | 2 +- src/views/media/MediaErrorView.tsx | 2 +- src/views/media/MediaView.tsx | 6 +++--- src/views/notfound/NotFoundView.tsx | 8 ++++---- src/views/search/HomeView.tsx | 4 ++-- src/views/search/SearchLoadingView.tsx | 2 +- src/views/search/SearchResultsView.tsx | 2 +- src/views/search/SearchView.tsx | 2 +- 20 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index e2fac636..aff10ea4 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -37,7 +37,7 @@ export function Dropdown(props: DropdownProps) { leaveFrom="opacity-100" leaveTo="opacity-0" > - + {props.options.map((opt) => ( diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 2513e409..4940cbc7 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -40,7 +40,7 @@ export function SearchBarInput(props: SearchBarProps) { return (
-
+
@@ -52,7 +52,7 @@ export function SearchBarInput(props: SearchBarProps) { placeholder={props.placeholder} /> -
+
{createPortal( -
+
{props.children} diff --git a/src/components/layout/Navigation.tsx b/src/components/layout/Navigation.tsx index df6d464b..a9a3e0c1 100644 --- a/src/components/layout/Navigation.tsx +++ b/src/components/layout/Navigation.tsx @@ -24,7 +24,7 @@ export function Navigation(props: NavigationProps) { top: `${bannerHeight}px`, }} > -
+
diff --git a/src/components/popout/FloatingCard.tsx b/src/components/popout/FloatingCard.tsx index 5a837d01..b4fd250c 100644 --- a/src/components/popout/FloatingCard.tsx +++ b/src/components/popout/FloatingCard.tsx @@ -167,7 +167,7 @@ export const FloatingCardView = {
{props.action ?? null}
-

+

{props.title}

{props.description}

diff --git a/src/video/components/VideoPlayer.tsx b/src/video/components/VideoPlayer.tsx index 6dc8983a..eca0477e 100644 --- a/src/video/components/VideoPlayer.tsx +++ b/src/video/components/VideoPlayer.tsx @@ -120,7 +120,7 @@ export function VideoPlayer(props: Props) { +

{source.source.quality}

diff --git a/src/video/components/parts/VideoErrorBoundary.tsx b/src/video/components/parts/VideoErrorBoundary.tsx index 8228f057..5786aa7a 100644 --- a/src/video/components/parts/VideoErrorBoundary.tsx +++ b/src/video/components/parts/VideoErrorBoundary.tsx @@ -64,7 +64,7 @@ export class VideoErrorBoundary extends Component< return (
-
+
-
+
diff --git a/src/views/developer/VideoTesterView.tsx b/src/views/developer/VideoTesterView.tsx index 4c3cb7e5..b192cd40 100644 --- a/src/views/developer/VideoTesterView.tsx +++ b/src/views/developer/VideoTesterView.tsx @@ -51,7 +51,7 @@ export default function VideoTesterView() { if (video) { return ( -
+
diff --git a/src/views/media/MediaErrorView.tsx b/src/views/media/MediaErrorView.tsx index e79d3521..b9c88012 100644 --- a/src/views/media/MediaErrorView.tsx +++ b/src/views/media/MediaErrorView.tsx @@ -14,7 +14,7 @@ export function MediaFetchErrorView() { {t("media.errors.failedMeta")} -
+
diff --git a/src/views/media/MediaView.tsx b/src/views/media/MediaView.tsx index 132161dd..c55211c7 100644 --- a/src/views/media/MediaView.tsx +++ b/src/views/media/MediaView.tsx @@ -34,7 +34,7 @@ function MediaViewLoading(props: { onGoBack(): void }) { {t("videoPlayer.loading")} -
+
@@ -68,7 +68,7 @@ function MediaViewScraping(props: MediaViewScrapingProps) { {props.meta.meta.title} -
+
@@ -134,7 +134,7 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) { } return ( -
+
diff --git a/src/views/notfound/NotFoundView.tsx b/src/views/notfound/NotFoundView.tsx index b7dfccf1..21ba4c63 100644 --- a/src/views/notfound/NotFoundView.tsx +++ b/src/views/notfound/NotFoundView.tsx @@ -23,7 +23,7 @@ export function NotFoundWrapper(props: { {t("notFound.genericTitle")} {props.video ? ( -
+
) : ( @@ -46,7 +46,7 @@ export function NotFoundMedia() { className="mb-6 text-xl text-bink-600" /> {t("notFound.media.title")} -

{t("notFound.media.description")}

+

{t("notFound.media.description")}

); @@ -62,7 +62,7 @@ export function NotFoundProvider() { className="mb-6 text-xl text-bink-600" /> {t("notFound.provider.title")} -

+

{t("notFound.provider.description")}

@@ -80,7 +80,7 @@ export function NotFoundPage() { className="mb-6 text-xl text-bink-600" /> {t("notFound.page.title")} -

{t("notFound.page.description")}

+

{t("notFound.page.description")}

); diff --git a/src/views/search/HomeView.tsx b/src/views/search/HomeView.tsx index bfb64695..a7a9a396 100644 --- a/src/views/search/HomeView.tsx +++ b/src/views/search/HomeView.tsx @@ -169,7 +169,7 @@ function NewDomainModal() { }} />
-
+
{t("v3.newDomain")}
@@ -186,7 +186,7 @@ function NewDomainModal() {

{t("v3.tireless")}

-
+
diff --git a/src/views/search/SearchLoadingView.tsx b/src/views/search/SearchLoadingView.tsx index 4c59d677..45971860 100644 --- a/src/views/search/SearchLoadingView.tsx +++ b/src/views/search/SearchLoadingView.tsx @@ -8,7 +8,7 @@ export function SearchLoadingView() { const [query] = useSearchQuery(); return ( +
-
+
From 1ade111757b89ac4ea52486d54094228fd7fa763 Mon Sep 17 00:00:00 2001 From: frost768 Date: Thu, 8 Jun 2023 04:08:17 +0300 Subject: [PATCH 2/7] thumbnails --- src/hooks/useProgressBar.ts | 8 +-- src/setup/sentry.tsx | 21 ++++--- src/utils/formatSeconds.ts | 21 +++++++ src/utils/thumbnailCreator.ts | 52 ++++++++++++++++ .../components/actions/BackdropAction.tsx | 35 ++++++----- .../components/actions/ProgressAction.tsx | 24 ++++++- .../components/actions/ThumbnailAction.tsx | 62 +++++++++++++++++++ src/video/components/actions/TimeAction.tsx | 23 +------ src/video/state/logic/source.ts | 2 + .../state/providers/castingStateProvider.ts | 1 + .../state/providers/videoStateProvider.ts | 11 ++++ src/video/state/types.ts | 2 + 12 files changed, 208 insertions(+), 54 deletions(-) create mode 100644 src/utils/formatSeconds.ts create mode 100644 src/utils/thumbnailCreator.ts create mode 100644 src/video/components/actions/ThumbnailAction.tsx diff --git a/src/hooks/useProgressBar.ts b/src/hooks/useProgressBar.ts index 252ed3b7..ca006746 100644 --- a/src/hooks/useProgressBar.ts +++ b/src/hooks/useProgressBar.ts @@ -1,10 +1,8 @@ import React, { RefObject, useCallback, useEffect, useState } from "react"; -type ActivityEvent = - | React.MouseEvent - | React.TouchEvent - | MouseEvent - | TouchEvent; +export type MouseActivity = React.MouseEvent | MouseEvent; + +type ActivityEvent = MouseActivity | React.TouchEvent | TouchEvent; export function makePercentageString(num: number) { return `${num.toFixed(2)}%`; diff --git a/src/setup/sentry.tsx b/src/setup/sentry.tsx index 268b31d7..8dae0b5a 100644 --- a/src/setup/sentry.tsx +++ b/src/setup/sentry.tsx @@ -4,13 +4,14 @@ import * as Sentry from "@sentry/react"; import { conf } from "@/setup/config"; import { SENTRY_DSN } from "@/setup/constants"; -Sentry.init({ - dsn: SENTRY_DSN, - release: `movie-web@${conf().APP_VERSION}`, - sampleRate: 0.5, - integrations: [ - new Sentry.BrowserTracing(), - new CaptureConsole(), - new HttpClient(), - ], -}); +if (process.env.NODE_ENV !== "development") + Sentry.init({ + dsn: SENTRY_DSN, + release: `movie-web@${conf().APP_VERSION}`, + sampleRate: 0.5, + integrations: [ + new Sentry.BrowserTracing(), + new CaptureConsole(), + new HttpClient(), + ], + }); diff --git a/src/utils/formatSeconds.ts b/src/utils/formatSeconds.ts new file mode 100644 index 00000000..8bec7401 --- /dev/null +++ b/src/utils/formatSeconds.ts @@ -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(":"); +} diff --git a/src/utils/thumbnailCreator.ts b/src/utils/thumbnailCreator.ts new file mode 100644 index 00000000..e67e1b4a --- /dev/null +++ b/src/utils/thumbnailCreator.ts @@ -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 { + 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: "" }; +} diff --git a/src/video/components/actions/BackdropAction.tsx b/src/video/components/actions/BackdropAction.tsx index 2aa60d38..90fe965a 100644 --- a/src/video/components/actions/BackdropAction.tsx +++ b/src/video/components/actions/BackdropAction.tsx @@ -22,22 +22,27 @@ export function BackdropAction(props: BackdropActionProps) { const lastTouchEnd = useRef(0); - const handleMouseMove = useCallback(() => { - if (!moved) { - setTimeout(() => { - // If NOT a touch, set moved to true - const isTouch = Date.now() - lastTouchEnd.current < 200; - if (!isTouch) setMoved(true); - }, 20); - } + const handleMouseMove = useCallback( + (e) => { + // to enable thumbnail on mouse hover + e.stopPropagation(); + if (!moved) { + setTimeout(() => { + // If NOT a touch, set moved to true + const isTouch = Date.now() - lastTouchEnd.current < 200; + if (!isTouch) setMoved(true); + }, 20); + } - // remove after all - if (timeout.current) clearTimeout(timeout.current); - timeout.current = setTimeout(() => { - setMoved(false); - timeout.current = null; - }, 3000); - }, [setMoved, moved]); + // remove after all + if (timeout.current) clearTimeout(timeout.current); + timeout.current = setTimeout(() => { + setMoved(false); + timeout.current = null; + }, 3000); + }, + [setMoved, moved] + ); const handleMouseLeave = useCallback(() => { setMoved(false); diff --git a/src/video/components/actions/ProgressAction.tsx b/src/video/components/actions/ProgressAction.tsx index 1e4ce3cf..789ad1d3 100644 --- a/src/video/components/actions/ProgressAction.tsx +++ b/src/video/components/actions/ProgressAction.tsx @@ -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(null); const dragRef = useRef(false); const controlRef = useRef(controls); - + const [hoverPosition, setHoverPosition] = useState(0); + const [isThumbnailVisible, setIsThumbnailVisible] = useState(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} >
+ {isThumbnailVisible ? ( + + ) : null}
diff --git a/src/video/components/actions/ThumbnailAction.tsx b/src/video/components/actions/ThumbnailAction.tsx new file mode 100644 index 00000000..9cc1c3ba --- /dev/null +++ b/src/video/components/actions/ThumbnailAction.tsx @@ -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; + 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 ( +
+ x.from < hoverTime && x.to > hoverTime + )?.imgUrl + } + /> +
+ {formatSeconds(hoverTime)} +
+
+ ); +} diff --git a/src/video/components/actions/TimeAction.tsx b/src/video/components/actions/TimeAction.tsx index 9a05d6fa..c53be300 100644 --- a/src/video/components/actions/TimeAction.tsx +++ b/src/video/components/actions/TimeAction.tsx @@ -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; diff --git a/src/video/state/logic/source.ts b/src/video/state/logic/source.ts index 5fafb60c..834e19b1 100644 --- a/src/video/state/logic/source.ts +++ b/src/video/state/logic/source.ts @@ -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[]; }; }; diff --git a/src/video/state/providers/castingStateProvider.ts b/src/video/state/providers/castingStateProvider.ts index e791c2f9..b4e8c6b2 100644 --- a/src/video/state/providers/castingStateProvider.ts +++ b/src/video/state/providers/castingStateProvider.ts @@ -154,6 +154,7 @@ export function createCastingStateProvider( caption: null, embedId: source.embedId, providerId: source.providerId, + thumbnails: [], }; resetStateForSource(descriptor, state); updateSource(descriptor, state); diff --git a/src/video/state/providers/videoStateProvider.ts b/src/video/state/providers/videoStateProvider.ts index 2f8c5beb..76e4d512 100644 --- a/src/video/state/providers/videoStateProvider.ts +++ b/src/video/state/providers/videoStateProvider.ts @@ -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) { diff --git a/src/video/state/types.ts b/src/video/state/types.ts index 9a6f3987..c4e1825f 100644 --- a/src/video/state/types.ts +++ b/src/video/state/types.ts @@ -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 From 4a36f98bf479a20002ca1b60a32cf7c2f07ce387 Mon Sep 17 00:00:00 2001 From: frost768 Date: Sat, 10 Jun 2023 14:38:26 +0300 Subject: [PATCH 3/7] fixed positioning and added loading icon --- src/setup/index.css | 4 ++ src/utils/thumbnailCreator.ts | 11 ++- .../components/actions/ProgressAction.tsx | 20 +++--- .../components/actions/ThumbnailAction.tsx | 67 ++++++++++++------- 4 files changed, 63 insertions(+), 39 deletions(-) diff --git a/src/setup/index.css b/src/setup/index.css index c17b8258..259aaa61 100644 --- a/src/setup/index.css +++ b/src/setup/index.css @@ -34,6 +34,10 @@ body[data-no-select] { animation: roll 1s; } +.roll-infinite { + animation: roll 2s infinite; +} + @keyframes roll { from { transform: rotate(0deg); diff --git a/src/utils/thumbnailCreator.ts b/src/utils/thumbnailCreator.ts index e67e1b4a..7f9a2556 100644 --- a/src/utils/thumbnailCreator.ts +++ b/src/utils/thumbnailCreator.ts @@ -3,11 +3,14 @@ export interface Thumbnail { to: number; imgUrl: string; } -export const SCALE_FACTOR = 0.1; +export const SCALE_FACTOR = 1; export default async function* extractThumbnails( videoUrl: string, numThumbnails: number ): AsyncGenerator { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) return { from: -1, to: -1, imgUrl: "" }; const video = document.createElement("video"); video.src = videoUrl; video.crossOrigin = "anonymous"; @@ -18,12 +21,8 @@ export default async function* extractThumbnails( 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; @@ -48,5 +47,5 @@ export default async function* extractThumbnails( }; } - return { from: 0, to: 0, imgUrl: "" }; + return { from: -1, to: -1, imgUrl: "" }; } diff --git a/src/video/components/actions/ProgressAction.tsx b/src/video/components/actions/ProgressAction.tsx index 789ad1d3..17e081e6 100644 --- a/src/video/components/actions/ProgressAction.tsx +++ b/src/video/components/actions/ProgressAction.tsx @@ -70,9 +70,11 @@ export function ProgressAction() { ); return ( -
+
- {isThumbnailVisible ? ( - - ) : null}
+ {isThumbnailVisible ? ( + + ) : null}
); } diff --git a/src/video/components/actions/ThumbnailAction.tsx b/src/video/components/actions/ThumbnailAction.tsx index 9cc1c3ba..53734d26 100644 --- a/src/video/components/actions/ThumbnailAction.tsx +++ b/src/video/components/actions/ThumbnailAction.tsx @@ -1,11 +1,13 @@ import { RefObject } from "react"; +import { Icon, Icons } from "@/components/Icon"; 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"; +const THUMBNAIL_HEIGHT = 100; export default function ThumbnailAction({ parentRef, hoverPosition, @@ -18,44 +20,61 @@ export default function ThumbnailAction({ const descriptor = useVideoPlayerDescriptor(); const source = useSource(descriptor); if (!parentRef.current) return null; - const offset = - (document.getElementsByTagName("video")[0].videoWidth * SCALE_FACTOR) / 2; + const videoEl = document.getElementsByTagName("video")[0]; + const aspectRatio = videoEl.videoWidth / videoEl.videoHeight; const rect = parentRef.current.getBoundingClientRect(); - + if (!rect.width) return null; const hoverPercent = (hoverPosition - rect.left) / rect.width; const hoverTime = videoTime.duration * hoverPercent; + const thumbnailWidth = THUMBNAIL_HEIGHT * aspectRatio; const pos = () => { const relativePosition = hoverPosition - rect.left; - if (relativePosition <= offset) { - return 0; + if (relativePosition <= thumbnailWidth / 2) { + return rect.left; } - if (relativePosition >= rect.width - offset) { - return rect.width - offset * 2; + if (relativePosition >= rect.width - thumbnailWidth / 2) { + return rect.width + rect.left - thumbnailWidth; } - return relativePosition - offset; + return relativePosition + rect.left - thumbnailWidth / 2; }; - + const src = source.source?.thumbnails.find( + (x) => x.from < hoverTime && x.to > hoverTime + )?.imgUrl; return ( -
- x.from < hoverTime && x.to > hoverTime - )?.imgUrl - } - /> +
+ {!src ? ( +
+ +
+ ) : ( + + )}
- {formatSeconds(hoverTime)} + {formatSeconds(hoverTime, videoEl.duration > 60 * 60)}
); From 5d5ab7671274dbac4aa36b629ad023db5e0a3606 Mon Sep 17 00:00:00 2001 From: frost768 Date: Sat, 10 Jun 2023 14:46:58 +0300 Subject: [PATCH 4/7] remove import --- src/video/components/actions/ThumbnailAction.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/video/components/actions/ThumbnailAction.tsx b/src/video/components/actions/ThumbnailAction.tsx index 53734d26..ef6d6c22 100644 --- a/src/video/components/actions/ThumbnailAction.tsx +++ b/src/video/components/actions/ThumbnailAction.tsx @@ -2,7 +2,6 @@ import { RefObject } from "react"; import { Icon, Icons } from "@/components/Icon"; 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"; From e105321b58927734fcb3d36a3d592c82c3ff88dd Mon Sep 17 00:00:00 2001 From: Emre Can Minnet Date: Wed, 14 Jun 2023 09:15:29 +0300 Subject: [PATCH 5/7] Update src/utils/thumbnailCreator.ts Co-authored-by: thehairy <71461991+thehairy@users.noreply.github.com> --- src/utils/thumbnailCreator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/thumbnailCreator.ts b/src/utils/thumbnailCreator.ts index 7f9a2556..14dddeb0 100644 --- a/src/utils/thumbnailCreator.ts +++ b/src/utils/thumbnailCreator.ts @@ -16,7 +16,7 @@ export default async function* extractThumbnails( video.crossOrigin = "anonymous"; // Wait for the video metadata to load - const metadata = await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { video.addEventListener("loadedmetadata", resolve); video.addEventListener("error", reject); }); From 50c2a552ab1dfa4e23dbc9c7a35320a9b2aa7abe Mon Sep 17 00:00:00 2001 From: frost768 Date: Thu, 22 Jun 2023 08:17:25 +0300 Subject: [PATCH 6/7] refactor(thumbnail): move code into react component --- src/utils/thumbnailCreator.ts | 51 ------- src/video/components/VideoPlayerBase.tsx | 2 + .../components/actions/ThumbnailAction.tsx | 128 ++++++++++++------ .../internal/ThumbnailGeneratorInternal.tsx | 114 ++++++++++++++++ .../popouts/CaptionSettingsPopout.tsx | 2 +- src/video/state/logic/source.ts | 3 +- .../state/providers/videoStateProvider.ts | 31 ++--- src/video/state/types.ts | 6 +- src/views/SettingsModal.tsx | 2 +- 9 files changed, 218 insertions(+), 121 deletions(-) delete mode 100644 src/utils/thumbnailCreator.ts create mode 100644 src/video/components/internal/ThumbnailGeneratorInternal.tsx diff --git a/src/utils/thumbnailCreator.ts b/src/utils/thumbnailCreator.ts deleted file mode 100644 index 14dddeb0..00000000 --- a/src/utils/thumbnailCreator.ts +++ /dev/null @@ -1,51 +0,0 @@ -export interface Thumbnail { - from: number; - to: number; - imgUrl: string; -} -export const SCALE_FACTOR = 1; -export default async function* extractThumbnails( - videoUrl: string, - numThumbnails: number -): AsyncGenerator { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) return { from: -1, to: -1, imgUrl: "" }; - const video = document.createElement("video"); - video.src = videoUrl; - video.crossOrigin = "anonymous"; - - // Wait for the video metadata to load - await new Promise((resolve, reject) => { - video.addEventListener("loadedmetadata", resolve); - video.addEventListener("error", reject); - }); - - canvas.height = video.videoHeight * SCALE_FACTOR; - canvas.width = video.videoWidth * SCALE_FACTOR; - - 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: -1, to: -1, imgUrl: "" }; -} diff --git a/src/video/components/VideoPlayerBase.tsx b/src/video/components/VideoPlayerBase.tsx index 62290da2..de4af01a 100644 --- a/src/video/components/VideoPlayerBase.tsx +++ b/src/video/components/VideoPlayerBase.tsx @@ -7,6 +7,7 @@ import { useInterface } from "@/video/state/logic/interface"; import { useMeta } from "@/video/state/logic/meta"; import { MetaAction } from "./actions/MetaAction"; +import ThumbnailGeneratorInternal from "./internal/ThumbnailGeneratorInternal"; import { VideoElementInternal } from "./internal/VideoElementInternal"; import { VideoPlayerContextProvider, @@ -48,6 +49,7 @@ function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) { ].join(" ")} > + diff --git a/src/video/components/actions/ThumbnailAction.tsx b/src/video/components/actions/ThumbnailAction.tsx index ef6d6c22..e2538057 100644 --- a/src/video/components/actions/ThumbnailAction.tsx +++ b/src/video/components/actions/ThumbnailAction.tsx @@ -1,4 +1,4 @@ -import { RefObject } from "react"; +import { RefObject, useMemo } from "react"; import { Icon, Icons } from "@/components/Icon"; import { formatSeconds } from "@/utils/formatSeconds"; @@ -7,6 +7,77 @@ import { VideoProgressEvent } from "@/video/state/logic/progress"; import { useSource } from "@/video/state/logic/source"; const THUMBNAIL_HEIGHT = 100; +function position( + rectLeft: number, + rectWidth: number, + thumbnailWidth: number, + hoverPos: number +): number { + const relativePosition = hoverPos - rectLeft; + if (relativePosition <= thumbnailWidth / 2) { + return rectLeft; + } + if (relativePosition >= rectWidth - thumbnailWidth / 2) { + return rectWidth + rectLeft - thumbnailWidth; + } + return relativePosition + rectLeft - thumbnailWidth / 2; +} +function useThumbnailWidth() { + const videoEl = useMemo(() => document.getElementsByTagName("video")[0], []); + const aspectRatio = videoEl.videoWidth / videoEl.videoHeight; + return THUMBNAIL_HEIGHT * aspectRatio; +} + +function LoadingThumbnail({ pos }: { pos: number }) { + const videoEl = useMemo(() => document.getElementsByTagName("video")[0], []); + const aspectRatio = videoEl.videoWidth / videoEl.videoHeight; + const thumbnailWidth = THUMBNAIL_HEIGHT * aspectRatio; + return ( +
+ +
+ ); +} + +function ThumbnailTime({ hoverTime, pos }: { hoverTime: number; pos: number }) { + const videoEl = useMemo(() => document.getElementsByTagName("video")[0], []); + const thumbnailWidth = useThumbnailWidth(); + return ( +
+ {formatSeconds(hoverTime, videoEl.duration > 60 * 60)} +
+ ); +} + +function ThumbnailImage({ src, pos }: { src: string; pos: number }) { + const thumbnailWidth = useThumbnailWidth(); + return ( + + ); +} export default function ThumbnailAction({ parentRef, hoverPosition, @@ -18,63 +89,32 @@ export default function ThumbnailAction({ }) { const descriptor = useVideoPlayerDescriptor(); const source = useSource(descriptor); + const thumbnailWidth = useThumbnailWidth(); if (!parentRef.current) return null; - const videoEl = document.getElementsByTagName("video")[0]; - const aspectRatio = videoEl.videoWidth / videoEl.videoHeight; const rect = parentRef.current.getBoundingClientRect(); if (!rect.width) return null; + const hoverPercent = (hoverPosition - rect.left) / rect.width; const hoverTime = videoTime.duration * hoverPercent; - - const thumbnailWidth = THUMBNAIL_HEIGHT * aspectRatio; - const pos = () => { - const relativePosition = hoverPosition - rect.left; - if (relativePosition <= thumbnailWidth / 2) { - return rect.left; - } - if (relativePosition >= rect.width - thumbnailWidth / 2) { - return rect.width + rect.left - thumbnailWidth; - } - return relativePosition + rect.left - thumbnailWidth / 2; - }; const src = source.source?.thumbnails.find( (x) => x.from < hoverTime && x.to > hoverTime )?.imgUrl; return ( -
+
{!src ? ( -
- -
+ ) : ( - )} -
- {formatSeconds(hoverTime, videoEl.duration > 60 * 60)} -
+
); } diff --git a/src/video/components/internal/ThumbnailGeneratorInternal.tsx b/src/video/components/internal/ThumbnailGeneratorInternal.tsx new file mode 100644 index 00000000..69fc33e7 --- /dev/null +++ b/src/video/components/internal/ThumbnailGeneratorInternal.tsx @@ -0,0 +1,114 @@ +import Hls from "hls.js"; +import { RefObject, useCallback, useEffect, useRef, useState } from "react"; + +import { MWStreamType } from "@/backend/helpers/streams"; +import { getPlayerState } from "@/video/state/cache"; +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { updateSource, useSource } from "@/video/state/logic/source"; +import { Thumbnail } from "@/video/state/types"; + +async function* generate( + videoUrl: string, + streamType: MWStreamType, + videoRef: RefObject, + canvasRef: RefObject, + numThumbnails = 20 +): AsyncGenerator { + const video = videoRef.current; + const canvas = canvasRef.current; + if (!video) return { from: -1, to: -1, imgUrl: "" }; + if (!canvas) return { from: -1, to: -1, imgUrl: "" }; + console.log("extracting started", streamType.toString()); + if (streamType === MWStreamType.HLS) { + const hls = new Hls(); + console.log("new hls instance"); + + hls.attachMedia(video); + hls.loadSource(videoUrl); + } + await new Promise((resolve, reject) => { + video.addEventListener("loadedmetadata", resolve); + video.addEventListener("error", reject); + }); + + canvas.height = video.videoHeight * 1; + canvas.width = video.videoWidth * 1; + let i = 0; + while (i < numThumbnails) { + const from = i * video.duration; + const to = (i + 1) * video.duration; + + // Seek to the specified time + video.currentTime = from; + console.log(from, to); + console.time("seek loaded"); + await new Promise((resolve) => { + video.addEventListener("seeked", resolve); + }); + console.timeEnd("seek loaded"); + console.log("loaded", video.currentTime, streamType.toString()); + + const ctx = canvas.getContext("2d"); + if (!ctx) return { from: -1, to: -1, imgUrl: "" }; + // 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(); + i += 1; + yield { + from, + to, + imgUrl, + }; + } + + return { from: -1, to: -1, imgUrl: "" }; +} + +export default function ThumbnailGeneratorInternal() { + const videoRef = useRef(document.createElement("video")); + const canvasRef = useRef(document.createElement("canvas")); + const descriptor = useVideoPlayerDescriptor(); + const source = useSource(descriptor); + const thumbnails = useRef([]); + const abortController = useRef(new AbortController()); + const generator = useCallback( + async (url: string, type: MWStreamType) => { + for await (const thumbnail of generate(url, type, videoRef, canvasRef)) { + if (abortController.current.signal.aborted) { + console.log("broke out of loop", type.toString()); + break; + } + + thumbnails.current = [...thumbnails.current, thumbnail]; + const state = getPlayerState(descriptor); + if (!state.source) return; + console.log("ran"); + state.source.thumbnails = thumbnails.current; + console.log(thumbnails.current); + + updateSource(descriptor, state); + console.log("ran 2"); + } + }, + [descriptor] + ); + + useEffect(() => { + const state = getPlayerState(descriptor); + if (!state.source) return; + const { url, type } = state.source; + generator(url, type); + }, [descriptor, generator, source.source?.url]); + + useEffect(() => { + const controller = abortController.current; + return () => { + console.log("abort"); + controller.abort(); + }; + }, []); + + return null; +} diff --git a/src/video/components/popouts/CaptionSettingsPopout.tsx b/src/video/components/popouts/CaptionSettingsPopout.tsx index a5abe5a6..1e2403be 100644 --- a/src/video/components/popouts/CaptionSettingsPopout.tsx +++ b/src/video/components/popouts/CaptionSettingsPopout.tsx @@ -71,7 +71,7 @@ export function CaptionSettingsPopout(props: {
{colors.map((color) => ( - + ))}
diff --git a/src/video/state/logic/source.ts b/src/video/state/logic/source.ts index 834e19b1..c8f09b47 100644 --- a/src/video/state/logic/source.ts +++ b/src/video/state/logic/source.ts @@ -1,11 +1,10 @@ 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"; -import { VideoPlayerState } from "../types"; +import { Thumbnail, VideoPlayerState } from "../types"; export type VideoSourceEvent = { source: null | { diff --git a/src/video/state/providers/videoStateProvider.ts b/src/video/state/providers/videoStateProvider.ts index 76e4d512..97802611 100644 --- a/src/video/state/providers/videoStateProvider.ts +++ b/src/video/state/providers/videoStateProvider.ts @@ -11,7 +11,6 @@ import { canWebkitFullscreen, canWebkitPictureInPicture, } from "@/utils/detectFeatures"; -import extractThumbnails from "@/utils/thumbnailCreator"; import { getStoredVolume, setStoredVolume, @@ -64,7 +63,6 @@ export function createVideoStateProvider( ): VideoPlayerStateProvider { const player = playerEl; const state = getPlayerState(descriptor); - return { getId() { return "video"; @@ -148,6 +146,16 @@ export function createVideoStateProvider( // reset before assign new one so the old HLS instance gets destroyed resetStateForSource(descriptor, state); + // update state + state.source = { + quality: source.quality, + type: source.type, + url: source.source, + caption: null, + embedId: source.embedId, + providerId: source.providerId, + thumbnails: [], + }; if (source?.type === MWStreamType.HLS) { if (player.canPlayType("application/vnd.apple.mpegurl")) { @@ -186,25 +194,6 @@ export function createVideoStateProvider( player.src = source.source; } - // update state - state.source = { - quality: source.quality, - type: source.type, - url: source.source, - 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) { diff --git a/src/video/state/types.ts b/src/video/state/types.ts index c4e1825f..71867902 100644 --- a/src/video/state/types.ts +++ b/src/video/state/types.ts @@ -6,10 +6,14 @@ import { MWStreamType, } from "@/backend/helpers/streams"; import { DetailedMeta } from "@/backend/metadata/getmeta"; -import { Thumbnail } from "@/utils/thumbnailCreator"; import { VideoPlayerStateProvider } from "./providers/providerTypes"; +export interface Thumbnail { + from: number; + to: number; + imgUrl: string; +} export type VideoPlayerMeta = { meta: DetailedMeta; captions: MWCaption[]; diff --git a/src/views/SettingsModal.tsx b/src/views/SettingsModal.tsx index 47de7888..2eb8adf6 100644 --- a/src/views/SettingsModal.tsx +++ b/src/views/SettingsModal.tsx @@ -122,7 +122,7 @@ export default function SettingsModal(props: {
{colors.map((color) => ( - + ))}
From 102123719105b86b438d7e2659efc9e21182fb62 Mon Sep 17 00:00:00 2001 From: frost768 Date: Sat, 24 Jun 2023 02:21:48 +0300 Subject: [PATCH 7/7] refactor(thumbnails): add index to continue from where left off - hls moved to ref - block loading thumbnail if there is no thumbnail at all --- .../components/actions/ThumbnailAction.tsx | 1 + .../internal/ThumbnailGeneratorInternal.tsx | 80 +++++++++---------- 2 files changed, 39 insertions(+), 42 deletions(-) diff --git a/src/video/components/actions/ThumbnailAction.tsx b/src/video/components/actions/ThumbnailAction.tsx index e2538057..cbb72374 100644 --- a/src/video/components/actions/ThumbnailAction.tsx +++ b/src/video/components/actions/ThumbnailAction.tsx @@ -99,6 +99,7 @@ export default function ThumbnailAction({ const src = source.source?.thumbnails.find( (x) => x.from < hoverTime && x.to > hoverTime )?.imgUrl; + if (!source.source?.thumbnails.length) return null; return (
{!src ? ( diff --git a/src/video/components/internal/ThumbnailGeneratorInternal.tsx b/src/video/components/internal/ThumbnailGeneratorInternal.tsx index 69fc33e7..993b1d5a 100644 --- a/src/video/components/internal/ThumbnailGeneratorInternal.tsx +++ b/src/video/components/internal/ThumbnailGeneratorInternal.tsx @@ -8,52 +8,36 @@ import { updateSource, useSource } from "@/video/state/logic/source"; import { Thumbnail } from "@/video/state/types"; async function* generate( - videoUrl: string, - streamType: MWStreamType, videoRef: RefObject, canvasRef: RefObject, + index = 0, numThumbnails = 20 ): AsyncGenerator { const video = videoRef.current; const canvas = canvasRef.current; if (!video) return { from: -1, to: -1, imgUrl: "" }; if (!canvas) return { from: -1, to: -1, imgUrl: "" }; - console.log("extracting started", streamType.toString()); - if (streamType === MWStreamType.HLS) { - const hls = new Hls(); - console.log("new hls instance"); - - hls.attachMedia(video); - hls.loadSource(videoUrl); - } await new Promise((resolve, reject) => { video.addEventListener("loadedmetadata", resolve); video.addEventListener("error", reject); }); - canvas.height = video.videoHeight * 1; - canvas.width = video.videoWidth * 1; - let i = 0; - while (i < numThumbnails) { - const from = i * video.duration; - const to = (i + 1) * video.duration; - - // Seek to the specified time + canvas.height = video.videoHeight; + canvas.width = video.videoWidth; + const ctx = canvas.getContext("2d"); + if (!ctx) return { from: -1, to: -1, imgUrl: "" }; + let i = index; + const limit = numThumbnails - 1; + const step = video.duration / limit; + while (i < limit && !Number.isNaN(video.duration)) { + const from = i * step; + const to = (i + 1) * step; video.currentTime = from; - console.log(from, to); - console.time("seek loaded"); await new Promise((resolve) => { video.addEventListener("seeked", resolve); }); - console.timeEnd("seek loaded"); - console.log("loaded", video.currentTime, streamType.toString()); - const ctx = canvas.getContext("2d"); - if (!ctx) return { from: -1, to: -1, imgUrl: "" }; - // 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(); i += 1; yield { @@ -67,48 +51,60 @@ async function* generate( } export default function ThumbnailGeneratorInternal() { - const videoRef = useRef(document.createElement("video")); - const canvasRef = useRef(document.createElement("canvas")); const descriptor = useVideoPlayerDescriptor(); const source = useSource(descriptor); + + const videoRef = useRef(document.createElement("video")); + const canvasRef = useRef(document.createElement("canvas")); + const hlsRef = useRef(new Hls()); const thumbnails = useRef([]); const abortController = useRef(new AbortController()); + const generator = useCallback( - async (url: string, type: MWStreamType) => { - for await (const thumbnail of generate(url, type, videoRef, canvasRef)) { + async (videoUrl: string, streamType: MWStreamType) => { + const prevIndex = thumbnails.current.length; + const video = videoRef.current; + if (streamType === MWStreamType.HLS) { + hlsRef.current.attachMedia(video); + hlsRef.current.loadSource(videoUrl); + } else { + video.crossOrigin = "anonymous"; + video.src = videoUrl; + } + + for await (const thumbnail of generate(videoRef, canvasRef, prevIndex)) { if (abortController.current.signal.aborted) { - console.log("broke out of loop", type.toString()); + if (streamType === MWStreamType.HLS) hlsRef.current.detachMedia(); + abortController.current = new AbortController(); + const state = getPlayerState(descriptor); + if (!state.source) return; + const { url, type } = state.source; + generator(url, type); break; } + if (thumbnail.from === -1) continue; thumbnails.current = [...thumbnails.current, thumbnail]; const state = getPlayerState(descriptor); if (!state.source) return; - console.log("ran"); state.source.thumbnails = thumbnails.current; - console.log(thumbnails.current); - updateSource(descriptor, state); - console.log("ran 2"); } }, [descriptor] ); useEffect(() => { + const controller = abortController.current; const state = getPlayerState(descriptor); if (!state.source) return; const { url, type } = state.source; generator(url, type); - }, [descriptor, generator, source.source?.url]); - - useEffect(() => { - const controller = abortController.current; return () => { - console.log("abort"); + if (!source.source?.url) return; controller.abort(); }; - }, []); + }, [descriptor, generator, source.source?.url]); return null; }