diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 5a232f51..a986fe60 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -40,6 +40,7 @@ export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button'; export { PlaybackRateButton, type PlaybackRateButtonProps } from './ui/playback-rate-button/playback-rate-button'; export { Poster, type PosterProps } from './ui/poster/poster'; export { SeekButton, type SeekButtonProps } from './ui/seek-button/seek-button'; +export { Thumbnail, type ThumbnailProps } from './ui/thumbnail/thumbnail'; export { Time } from './ui/time'; // Utilities diff --git a/packages/react/src/ui/thumbnail/thumbnail.tsx b/packages/react/src/ui/thumbnail/thumbnail.tsx new file mode 100644 index 00000000..60a9d910 --- /dev/null +++ b/packages/react/src/ui/thumbnail/thumbnail.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { + mapCuesToThumbnails, + ThumbnailCore, + ThumbnailDataAttrs, + type ThumbnailFetchPriority, + type ThumbnailImage, +} from '@videojs/core'; +import { createThumbnail, selectTextTrack } from '@videojs/core/dom'; +import type { CSSProperties } from 'react'; +import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'; + +import { usePlayer } from '../../player/context'; +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; + +export interface ThumbnailProps extends UIComponentProps<'div', ThumbnailCore.State>, ThumbnailCore.Props { + /** Pre-parsed thumbnail images — bypasses the automatic `` detection. */ + thumbnails?: ThumbnailImage[] | undefined; +} + +export const Thumbnail = forwardRef(function Thumbnail(componentProps, forwardedRef) { + const { + render, + className, + style, + time = 0, + thumbnails: externalThumbnails, + crossOrigin, + loading, + fetchPriority, + ...elementProps + } = componentProps; + + const [core] = useState(() => new ThumbnailCore()); + const divRef = useRef(null); + const imgRef = useRef(null); + const textTrack = usePlayer(selectTextTrack); + + // Force re-render when the handle's state changes (img load/error, resize). + const [, setRenderToken] = useState(0); + + const [handle] = useState(() => + createThumbnail({ + getContainer: () => divRef.current, + getImg: () => imgRef.current, + onStateChange: () => setRenderToken((n) => n + 1), + }) + ); + + useEffect(() => { + handle.connect(); + return () => handle.destroy(); + }, [handle]); + + // Resolve thumbnails: external prop takes priority over auto path. + const thumbnails = useMemo(() => { + if (externalThumbnails && externalThumbnails.length > 0) return externalThumbnails; + if (!textTrack?.thumbnailCues.length) return []; + return mapCuesToThumbnails(textTrack.thumbnailCues, textTrack.thumbnailTrackSrc ?? undefined); + }, [externalThumbnails, textTrack]); + + const thumbnail = useMemo(() => core.findActiveThumbnail(thumbnails, time), [core, thumbnails, time]); + + // Track src changes via the handle. + handle.updateSrc(thumbnail?.url); + + const state = core.getState(handle.loading, handle.error, thumbnail); + + // Compute styles declaratively from resize result. + let containerStyle: CSSProperties = { overflow: 'hidden' }; + let imgStyle: CSSProperties | undefined; + + if (thumbnail && handle.naturalWidth && handle.naturalHeight) { + const constraints = handle.readConstraints(); + const result = core.resize(thumbnail, handle.naturalWidth, handle.naturalHeight, constraints); + + if (result) { + containerStyle = { + overflow: 'hidden', + width: result.containerWidth, + height: result.containerHeight, + }; + imgStyle = { + width: result.imageWidth, + height: result.imageHeight, + maxWidth: 'none', + transform: + result.offsetX || result.offsetY ? `translate(-${result.offsetX}px, -${result.offsetY}px)` : undefined, + }; + } + } + + return renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap: ThumbnailDataAttrs, + ref: [forwardedRef, divRef], + props: [ + core.getAttrs(state), + { style: containerStyle }, + elementProps, + { + children: ( + + ), + }, + ], + } + ); +}); + +export namespace Thumbnail { + export type Props = ThumbnailProps; + export type State = ThumbnailCore.State; +}