16 changed files with 340 additions and 69 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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