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/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/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/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/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..17e081e6 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]); @@ -59,12 +70,16 @@ export function ProgressAction() { ); return ( -
+
+ {isThumbnailVisible ? ( + + ) : null}
); } diff --git a/src/video/components/actions/ThumbnailAction.tsx b/src/video/components/actions/ThumbnailAction.tsx new file mode 100644 index 00000000..cbb72374 --- /dev/null +++ b/src/video/components/actions/ThumbnailAction.tsx @@ -0,0 +1,121 @@ +import { RefObject, useMemo } from "react"; + +import { Icon, Icons } from "@/components/Icon"; +import { formatSeconds } from "@/utils/formatSeconds"; +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +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, + videoTime, +}: { + parentRef: RefObject; + hoverPosition: number; + videoTime: VideoProgressEvent; +}) { + const descriptor = useVideoPlayerDescriptor(); + const source = useSource(descriptor); + const thumbnailWidth = useThumbnailWidth(); + if (!parentRef.current) return null; + const rect = parentRef.current.getBoundingClientRect(); + if (!rect.width) return null; + + const hoverPercent = (hoverPosition - rect.left) / rect.width; + const hoverTime = videoTime.duration * hoverPercent; + 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/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/components/internal/ThumbnailGeneratorInternal.tsx b/src/video/components/internal/ThumbnailGeneratorInternal.tsx new file mode 100644 index 00000000..993b1d5a --- /dev/null +++ b/src/video/components/internal/ThumbnailGeneratorInternal.tsx @@ -0,0 +1,110 @@ +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( + 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: "" }; + await new Promise((resolve, reject) => { + video.addEventListener("loadedmetadata", resolve); + video.addEventListener("error", reject); + }); + + 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; + await new Promise((resolve) => { + video.addEventListener("seeked", resolve); + }); + + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const imgUrl = canvas.toDataURL(); + i += 1; + yield { + from, + to, + imgUrl, + }; + } + + return { from: -1, to: -1, imgUrl: "" }; +} + +export default function ThumbnailGeneratorInternal() { + 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 (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) { + 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; + state.source.thumbnails = thumbnails.current; + updateSource(descriptor, state); + } + }, + [descriptor] + ); + + useEffect(() => { + const controller = abortController.current; + const state = getPlayerState(descriptor); + if (!state.source) return; + const { url, type } = state.source; + generator(url, type); + return () => { + if (!source.source?.url) return; + controller.abort(); + }; + }, [descriptor, generator, source.source?.url]); + + 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 5fafb60c..c8f09b47 100644 --- a/src/video/state/logic/source.ts +++ b/src/video/state/logic/source.ts @@ -4,7 +4,7 @@ import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; import { getPlayerState } from "../cache"; import { listenEvent, sendEvent, unlistenEvent } from "../events"; -import { VideoPlayerState } from "../types"; +import { Thumbnail, VideoPlayerState } from "../types"; export type VideoSourceEvent = { source: null | { @@ -17,6 +17,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..97802611 100644 --- a/src/video/state/providers/videoStateProvider.ts +++ b/src/video/state/providers/videoStateProvider.ts @@ -63,7 +63,6 @@ export function createVideoStateProvider( ): VideoPlayerStateProvider { const player = playerEl; const state = getPlayerState(descriptor); - return { getId() { return "video"; @@ -147,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")) { @@ -185,15 +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, - }; updateSource(descriptor, state); }, setCaption(id, url) { diff --git a/src/video/state/types.ts b/src/video/state/types.ts index 9a6f3987..71867902 100644 --- a/src/video/state/types.ts +++ b/src/video/state/types.ts @@ -9,6 +9,11 @@ import { DetailedMeta } from "@/backend/metadata/getmeta"; import { VideoPlayerStateProvider } from "./providers/providerTypes"; +export interface Thumbnail { + from: number; + to: number; + imgUrl: string; +} export type VideoPlayerMeta = { meta: DetailedMeta; captions: MWCaption[]; @@ -75,6 +80,7 @@ export type VideoPlayerState = { url: string; id: string; }; + thumbnails: Thumbnail[]; }; // casting state 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) => ( - + ))}