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) => ( - + ))}