feat(packages): add poster placeholder blur-up pattern (#1632)

This commit is contained in:
Ronald Urbina
2026-06-18 12:05:41 -03:00
committed by GitHub
parent b32b80e55b
commit 07428519a5
24 changed files with 417 additions and 35 deletions
@@ -17,7 +17,7 @@ import {
} from '@videojs/skins/minimal/tailwind/video.tailwind';
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { type ComponentProps, type CSSProperties, forwardRef, type ReactNode } from 'react';
import {
AirPlayEnterIcon,
AirPlayExitIcon,
@@ -196,10 +196,14 @@ function CaptionsTrigger(): ReactNode {
}
export function MinimalLiveVideoSkinTailwind(props: MinimalLiveVideoSkinProps): ReactNode {
const { children, className, poster: posterProp, ...rest } = props;
const { children, className, poster: posterProp, placeholder, style, ...rest } = props;
const containerStyle = placeholder
? ({ '--media-poster-placeholder': `url(${placeholder})`, ...style } as CSSProperties)
: style;
return (
<Container className={cn(root(false), className)} {...rest}>
<Container className={cn(root(false), className)} style={containerStyle} {...rest}>
{children}
{posterProp && (
@@ -1,6 +1,6 @@
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { type ComponentProps, type CSSProperties, forwardRef, type ReactNode } from 'react';
import {
AirPlayEnterIcon,
AirPlayExitIcon,
@@ -165,10 +165,18 @@ function CaptionsTrigger(): ReactNode {
}
export function MinimalLiveVideoSkin(props: MinimalLiveVideoSkinProps): ReactNode {
const { children, className, poster, ...rest } = props;
const { children, className, poster, placeholder, style, ...rest } = props;
const containerStyle = placeholder
? ({ '--media-poster-placeholder': `url(${placeholder})`, ...style } as CSSProperties)
: style;
return (
<Container className={cn('media-minimal-skin media-minimal-skin--video', className)} {...rest}>
<Container
className={cn('media-minimal-skin media-minimal-skin--video', className)}
style={containerStyle}
{...rest}
>
{children}
{poster && (
@@ -17,7 +17,7 @@ import {
} from '@videojs/skins/default/tailwind/video.tailwind';
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { type ComponentProps, type CSSProperties, forwardRef, type ReactNode } from 'react';
import {
AirPlayEnterIcon,
AirPlayExitIcon,
@@ -196,10 +196,14 @@ function CaptionsTrigger(): ReactNode {
}
export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode {
const { children, className, poster: posterProp, ...rest } = props;
const { children, className, poster: posterProp, placeholder, style, ...rest } = props;
const containerStyle = placeholder
? ({ '--media-poster-placeholder': `url(${placeholder})`, ...style } as CSSProperties)
: style;
return (
<Container className={cn(root(false), className)} {...rest}>
<Container className={cn(root(false), className)} style={containerStyle} {...rest}>
{children}
{posterProp && (
+11 -3
View File
@@ -1,6 +1,6 @@
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { type ComponentProps, type CSSProperties, forwardRef, type ReactNode } from 'react';
import {
AirPlayEnterIcon,
AirPlayExitIcon,
@@ -157,10 +157,18 @@ function CaptionsTrigger(): ReactNode {
}
export function LiveVideoSkin(props: LiveVideoSkinProps): ReactNode {
const { children, className, poster, ...rest } = props;
const { children, className, poster, placeholder, style, ...rest } = props;
const containerStyle = placeholder
? ({ '--media-poster-placeholder': `url(${placeholder})`, ...style } as CSSProperties)
: style;
return (
<Container className={cn('media-default-skin media-default-skin--video', className)} {...rest}>
<Container
className={cn('media-default-skin media-default-skin--video', className)}
style={containerStyle}
{...rest}
>
{children}
{poster && (
+2
View File
@@ -11,4 +11,6 @@ export type BaseSkinProps<T = unknown> = PropsWithChildren<
export type BaseVideoSkinProps<T = unknown> = BaseSkinProps<T> & {
poster?: string | RenderProp<Poster.State> | undefined;
/** Low-resolution placeholder shown behind the poster while it loads (blur-up effect). */
placeholder?: string | undefined;
};
@@ -23,7 +23,7 @@ import {
} from '@videojs/skins/minimal/tailwind/video.tailwind';
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { type ComponentProps, type CSSProperties, forwardRef, type ReactNode } from 'react';
import {
AirPlayEnterIcon,
AirPlayExitIcon,
@@ -346,10 +346,14 @@ function SettingsMenu(): ReactNode {
/* ------------------------------------------ Skin ------------------------------------------- */
export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNode {
const { children, className, poster: posterProp, ...rest } = props;
const { children, className, poster: posterProp, placeholder, style, ...rest } = props;
const containerStyle = placeholder
? ({ '--media-poster-placeholder': `url(${placeholder})`, ...style } as CSSProperties)
: style;
return (
<Container className={cn(root(false), className)} {...rest}>
<Container className={cn(root(false), className)} style={containerStyle} {...rest}>
{children}
{posterProp && (
@@ -1,6 +1,6 @@
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { type ComponentProps, type CSSProperties, forwardRef, type ReactNode } from 'react';
import {
AirPlayEnterIcon,
AirPlayExitIcon,
@@ -283,10 +283,18 @@ function SettingsMenu(): ReactNode {
}
export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
const { children, className, poster, ...rest } = props;
const { children, className, poster, placeholder, style, ...rest } = props;
const containerStyle = placeholder
? ({ '--media-poster-placeholder': `url(${placeholder})`, ...style } as CSSProperties)
: style;
return (
<Container className={cn('media-minimal-skin media-minimal-skin--video', className)} {...rest}>
<Container
className={cn('media-minimal-skin media-minimal-skin--video', className)}
style={containerStyle}
{...rest}
>
{children}
{poster && (
@@ -23,7 +23,7 @@ import {
} from '@videojs/skins/default/tailwind/video.tailwind';
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { type ComponentProps, type CSSProperties, forwardRef, type ReactNode } from 'react';
import {
AirPlayEnterIcon,
AirPlayExitIcon,
@@ -346,10 +346,14 @@ function SettingsMenu(): ReactNode {
/* ------------------------------------------ Skin ------------------------------------------- */
export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
const { children, className, poster: posterProp, ...rest } = props;
const { children, className, poster: posterProp, placeholder, style, ...rest } = props;
const containerStyle = placeholder
? ({ '--media-poster-placeholder': `url(${placeholder})`, ...style } as CSSProperties)
: style;
return (
<Container className={cn(root(false), className)} {...rest}>
<Container className={cn(root(false), className)} style={containerStyle} {...rest}>
{children}
{posterProp && (
+11 -3
View File
@@ -1,6 +1,6 @@
import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { type ComponentProps, type CSSProperties, forwardRef, type ReactNode } from 'react';
import {
AirPlayEnterIcon,
AirPlayExitIcon,
@@ -283,10 +283,18 @@ function SettingsMenu(): ReactNode {
}
export function VideoSkin(props: VideoSkinProps): ReactNode {
const { children, className, poster, ...rest } = props;
const { children, className, poster, placeholder, style, ...rest } = props;
const containerStyle = placeholder
? ({ '--media-poster-placeholder': `url(${placeholder})`, ...style } as CSSProperties)
: style;
return (
<Container className={cn('media-default-skin media-default-skin--video', className)} {...rest}>
<Container
className={cn('media-default-skin media-default-skin--video', className)}
style={containerStyle}
{...rest}
>
{children}
{poster && (
+24 -4
View File
@@ -2,8 +2,8 @@
import { PosterCore, PosterDataAttrs } from '@videojs/core';
import { logMissingFeature, selectPlayback } from '@videojs/core/dom';
import type { ForwardedRef } from 'react';
import { forwardRef, useState } from 'react';
import type { ForwardedRef, SyntheticEvent } from 'react';
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
import { usePlayer } from '../../player/context';
import type { UIComponentProps } from '../../utils/types';
@@ -35,6 +35,26 @@ export const Poster = forwardRef(function Poster(
const [core] = useState(() => new PosterCore());
// Track when the current src has finished loading so the CSS blur-up
// sequence can show the placeholder first, then crossfade to the full image.
const src = (elementProps as { src?: string }).src;
const [loadedSrc, setLoadedSrc] = useState<string | undefined>(undefined);
const loaded = loadedSrc === src;
const imgRef = useRef<HTMLImageElement | null>(null);
// A cached image may already be complete when the element mounts, in which
// case onLoad never fires. Check synchronously after mount and on src change.
useEffect(() => {
const img = imgRef.current;
if (img?.complete && img.naturalWidth > 0 && img.getAttribute('src') === src) {
setLoadedSrc(src);
}
}, [src]);
const handleLoad = useCallback((event: SyntheticEvent<HTMLImageElement>) => {
setLoadedSrc(event.currentTarget.getAttribute('src') ?? undefined);
}, []);
if (!playback) {
if (__DEV__) logMissingFeature('Poster', 'playback');
return null;
@@ -48,8 +68,8 @@ export const Poster = forwardRef(function Poster(
{
state: core.getState(),
stateAttrMap: PosterDataAttrs,
ref: [forwardedRef],
props: [elementProps],
ref: [forwardedRef, imgRef],
props: [elementProps, { 'data-loaded': loaded ? '' : undefined, onLoad: handleLoad }],
}
);
});