16 changed files with 340 additions and 69 deletions
@ -0,0 +1,21 @@
@@ -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(":"); |
||||
} |
@ -0,0 +1,121 @@
@@ -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 ( |
||||
<div |
||||
className="absolute bottom-32 flex items-center justify-center rounded bg-black" |
||||
style={{ |
||||
left: `${pos}px`, |
||||
width: `${thumbnailWidth}px`, |
||||
height: `${THUMBNAIL_HEIGHT}px`, |
||||
}} |
||||
> |
||||
<Icon |
||||
className="roll-infinite text-6xl text-bink-600" |
||||
icon={Icons.MOVIE_WEB} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function ThumbnailTime({ hoverTime, pos }: { hoverTime: number; pos: number }) { |
||||
const videoEl = useMemo(() => document.getElementsByTagName("video")[0], []); |
||||
const thumbnailWidth = useThumbnailWidth(); |
||||
return ( |
||||
<div |
||||
className="absolute bottom-24 text-white" |
||||
style={{ |
||||
left: `${pos + thumbnailWidth / 2 - 18}px`, |
||||
}} |
||||
> |
||||
{formatSeconds(hoverTime, videoEl.duration > 60 * 60)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function ThumbnailImage({ src, pos }: { src: string; pos: number }) { |
||||
const thumbnailWidth = useThumbnailWidth(); |
||||
return ( |
||||
<img |
||||
height={THUMBNAIL_HEIGHT} |
||||
width={thumbnailWidth} |
||||
className="absolute bottom-32 rounded" |
||||
src={src} |
||||
style={{ |
||||
left: `${pos}px`, |
||||
}} |
||||
/> |
||||
); |
||||
} |
||||
export default function ThumbnailAction({ |
||||
parentRef, |
||||
hoverPosition, |
||||
videoTime, |
||||
}: { |
||||
parentRef: RefObject<HTMLDivElement>; |
||||
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 ( |
||||
<div className="pointer-events-none"> |
||||
{!src ? ( |
||||
<LoadingThumbnail |
||||
pos={position(rect.left, rect.width, thumbnailWidth, hoverPosition)} |
||||
/> |
||||
) : ( |
||||
<ThumbnailImage |
||||
pos={position(rect.left, rect.width, thumbnailWidth, hoverPosition)} |
||||
src={src} |
||||
/> |
||||
)} |
||||
<ThumbnailTime |
||||
hoverTime={hoverTime} |
||||
pos={position(rect.left, rect.width, thumbnailWidth, hoverPosition)} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,110 @@
@@ -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<HTMLVideoElement>, |
||||
canvasRef: RefObject<HTMLCanvasElement>, |
||||
index = 0, |
||||
numThumbnails = 20 |
||||
): AsyncGenerator<Thumbnail, Thumbnail> { |
||||
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<HTMLVideoElement>(document.createElement("video")); |
||||
const canvasRef = useRef<HTMLCanvasElement>(document.createElement("canvas")); |
||||
const hlsRef = useRef<Hls>(new Hls()); |
||||
const thumbnails = useRef<Thumbnail[]>([]); |
||||
const abortController = useRef<AbortController>(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; |
||||
} |
Loading…
Reference in new issue