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