mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages): add poster placeholder blur-up pattern (#1632)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { getPlaceholderSrc } from '../sources';
|
||||
import { useSource } from './use-source';
|
||||
|
||||
export function usePlaceholder() {
|
||||
const source = useSource();
|
||||
return useMemo(() => getPlaceholderSrc(source), [source]);
|
||||
}
|
||||
@@ -91,6 +91,11 @@ export function getPosterSrc(source: SourceId): string | undefined {
|
||||
return id ? `https://image.mux.com/${id}/thumbnail.jpg` : undefined;
|
||||
}
|
||||
|
||||
export function getPlaceholderSrc(source: SourceId): string | undefined {
|
||||
const id = getMuxAssetId(source);
|
||||
return id ? `https://image.mux.com/${id}/thumbnail.jpg?width=20` : undefined;
|
||||
}
|
||||
|
||||
export function getStoryboardSrc(source: SourceId): string | undefined {
|
||||
// Storyboards aren't generated for live streams, so skip the request entirely.
|
||||
if (isLiveSource(source)) return undefined;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
onSkinChange,
|
||||
onSourceChange,
|
||||
} from '@app/shared/sandbox-listener';
|
||||
import { getPosterSrc, getStoryboardSrc, isLiveSource, SOURCES } from '@app/shared/sources';
|
||||
import { getPlaceholderSrc, getPosterSrc, getStoryboardSrc, isLiveSource, SOURCES } from '@app/shared/sources';
|
||||
|
||||
const html = String.raw;
|
||||
|
||||
@@ -26,12 +26,13 @@ async function render() {
|
||||
|
||||
const storyboard = getStoryboardSrc(state.source);
|
||||
const poster = getPosterSrc(state.source);
|
||||
const placeholder = getPlaceholderSrc(state.source);
|
||||
const mediaAttrs = renderMediaAttrs(state);
|
||||
const playerTag = live ? 'live-video-player' : 'video-player';
|
||||
|
||||
document.getElementById('root')!.innerHTML = html`
|
||||
<${playerTag}>
|
||||
<${tag} class="aspect-video max-w-4xl mx-auto">
|
||||
<${tag} class="aspect-video max-w-4xl mx-auto"${placeholder ? ` placeholdersrc="${placeholder}"` : ''}>
|
||||
<mux-video src="${SOURCES[state.source].url}" ${mediaAttrs} playsinline crossorigin="anonymous">
|
||||
${renderStoryboard(storyboard)}
|
||||
</mux-video>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Storyboard } from '@app/shared/react/storyboard';
|
||||
import { useAutoplay } from '@app/shared/react/use-autoplay';
|
||||
import { useLoop } from '@app/shared/react/use-loop';
|
||||
import { useMuted } from '@app/shared/react/use-muted';
|
||||
import { usePlaceholder } from '@app/shared/react/use-placeholder';
|
||||
import { usePoster } from '@app/shared/react/use-poster';
|
||||
import { usePreload } from '@app/shared/react/use-preload';
|
||||
import { useSkin } from '@app/shared/react/use-skin';
|
||||
@@ -25,6 +26,7 @@ function App() {
|
||||
const source = useSource();
|
||||
const styling = useMemo(readStyling, []);
|
||||
const poster = usePoster();
|
||||
const placeholder = usePlaceholder();
|
||||
const storyboard = useStoryboard();
|
||||
const live = isLiveSource(source);
|
||||
const autoplay = useAutoplay();
|
||||
@@ -37,6 +39,7 @@ function App() {
|
||||
<Provider>
|
||||
<VideoSkinComponent
|
||||
poster={poster}
|
||||
placeholder={placeholder}
|
||||
skin={skin}
|
||||
styling={styling}
|
||||
live={live}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
status: implemented
|
||||
date: 2026-05-28
|
||||
---
|
||||
|
||||
# Poster Placeholder
|
||||
|
||||
Low-resolution placeholder image shown before the full poster loads, enabling blur-up progressive-loading UX. Improves perceived performance on slow connections.
|
||||
|
||||
## Problem
|
||||
|
||||
The poster image is often the first visual users see. On slow connections it may take several hundred milliseconds to load, leaving a blank area. A blurred low-resolution placeholder — typically a [blurhash](https://blurha.sh/) or [palette-based data URI](https://github.com/muxinc/blurup) — can fill that space immediately from an inline data URI, then transition to the full image as it loads.
|
||||
|
||||
The existing `Poster` component (React) and `PosterElement` (HTML) exposed no mechanism for this. Users who needed it had to compose their own layering solution.
|
||||
|
||||
## Solution
|
||||
|
||||
Both the React `Poster` component and the HTML `PosterElement` accept a placeholder URL or data URI. When provided, the placeholder renders as the `background-image` of a `::before` pseudo-element positioned behind the poster. A `filter: blur()` is applied to create the blur-up effect.
|
||||
|
||||
This matches how Media Chrome implements `placeholdersrc` and Mux Player implements `placeholder`.
|
||||
|
||||
### React
|
||||
|
||||
```tsx
|
||||
<VideoPlayer poster="poster.jpg" placeholder={blurDataURL} />
|
||||
```
|
||||
|
||||
The `placeholder` prop is accepted on `BaseVideoSkinProps` and all skin variants (`VideoSkin`, `LiveVideoSkin`, and their minimal equivalents). When provided, the skin sets `--media-poster-placeholder` as an inline CSS custom property on the container element:
|
||||
|
||||
```tsx
|
||||
const containerStyle = placeholder
|
||||
? ({ '--media-poster-placeholder': `url(${placeholder})`, ...style } as CSSProperties)
|
||||
: style;
|
||||
```
|
||||
|
||||
The skin CSS then renders the placeholder via `::before` on the container, with an `opacity` fade-in triggered by `:has(> img[data-visible])` once the full poster is loaded:
|
||||
|
||||
```css
|
||||
.media-default-skin::before {
|
||||
/* positioned layer behind the poster */
|
||||
background-image: var(--media-poster-placeholder, none);
|
||||
filter: blur(var(--media-poster-placeholder-blur, 20px));
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
.media-default-skin:has(> img[data-visible])::before {
|
||||
opacity: 1;
|
||||
}
|
||||
```
|
||||
|
||||
The placeholder is intentionally hidden until the skin detects a visible poster (`data-visible`). This avoids a flash of the blurred image when no poster is shown (e.g. after playback starts).
|
||||
|
||||
### HTML
|
||||
|
||||
```html
|
||||
<media-poster placeholdersrc="data:image/jpeg;base64,...">
|
||||
<img src="poster.jpg" alt="Video title" />
|
||||
</media-poster>
|
||||
```
|
||||
|
||||
`PosterElement` observes the `placeholdersrc` attribute and sets `--media-poster-placeholder` as an inline CSS custom property on itself:
|
||||
|
||||
```ts
|
||||
// In PosterElement.attributeChangedCallback
|
||||
if (newValue) {
|
||||
this.style.setProperty('--media-poster-placeholder', `url(${newValue})`);
|
||||
} else {
|
||||
this.style.removeProperty('--media-poster-placeholder');
|
||||
}
|
||||
```
|
||||
|
||||
The skin picks up the variable via `::before` on `media-poster`. No opacity transition is needed on the HTML path — the `media-poster` element itself transitions in via its existing `opacity` rule keyed on `[data-visible]`, so the `::before` appears and disappears with it.
|
||||
|
||||
```css
|
||||
.media-default-skin media-poster::before {
|
||||
background-image: var(--media-poster-placeholder, none);
|
||||
filter: blur(var(--media-poster-placeholder-blur, 20px));
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
The placeholder is a separate absolutely-positioned layer rendered via CSS `::before`, not part of the `<img>` element itself. This avoids interfering with the poster's `object-fit`/`object-position` or `src` loading.
|
||||
|
||||
**React path:**
|
||||
|
||||
1. Skin container gets `--media-poster-placeholder` via inline style.
|
||||
2. `::before` on the container renders the blurred placeholder at `opacity: 0`.
|
||||
3. When the `<img>` inside gets `data-visible`, `:has()` flips `::before` to `opacity: 1` — the placeholder fades in.
|
||||
4. When the poster hides (after playback starts), the container's `opacity` transitions to `0`, taking `::before` with it.
|
||||
|
||||
**HTML path:**
|
||||
|
||||
1. `PosterElement` sets `--media-poster-placeholder` on itself via `attributeChangedCallback`.
|
||||
2. `::before` on `media-poster` renders the blurred placeholder, always visible while the element is visible.
|
||||
3. `media-poster[data-visible]` / `media-poster:not([data-visible])` control the element's own opacity, so placeholder visibility is tied to the element's lifecycle.
|
||||
|
||||
## CSS Custom Properties
|
||||
|
||||
| Property | Value |
|
||||
| --- | --- |
|
||||
| `--media-poster-placeholder` | Set by the component/element to `url(...)` |
|
||||
| `--media-poster-placeholder-blur` | Controls blur radius; defaults to `20px` |
|
||||
| `--media-object-position` | Aligns placeholder to match poster position |
|
||||
| `--media-object-fit` | Sizes placeholder to match poster fit |
|
||||
|
||||
`background-size` and `background-position` use `--media-object-fit` and `--media-object-position` so the placeholder aligns exactly with the poster.
|
||||
|
||||
## Skin Integration
|
||||
|
||||
Both `default` and `minimal` CSS skins implement both paths identically.
|
||||
|
||||
**HTML path** — `::before` on `media-poster`:
|
||||
|
||||
```css
|
||||
.media-default-skin media-poster::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
background-image: var(--media-poster-placeholder, none);
|
||||
background-repeat: no-repeat;
|
||||
background-position: var(--media-object-position, center);
|
||||
background-size: var(--media-object-fit, contain);
|
||||
filter: blur(var(--media-poster-placeholder-blur, 20px));
|
||||
}
|
||||
```
|
||||
|
||||
**React path** — `::before` on the skin container with fade-in:
|
||||
|
||||
```css
|
||||
.media-default-skin::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
background-image: var(--media-poster-placeholder, none);
|
||||
background-repeat: no-repeat;
|
||||
background-position: var(--media-object-position, center);
|
||||
background-size: var(--media-object-fit, contain);
|
||||
opacity: 0;
|
||||
filter: blur(var(--media-poster-placeholder-blur, 20px));
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
.media-default-skin:has(> img[data-visible])::before {
|
||||
opacity: 1;
|
||||
}
|
||||
```
|
||||
|
||||
Tailwind skin variants wire `--media-poster-placeholder` the same way as the CSS skins — via inline style on the container — and rely on the same `::before` rules.
|
||||
|
||||
## Accessibility
|
||||
|
||||
The placeholder is purely decorative — a blurred version of the poster that exists only to fill space during loading. Rendering it as a CSS `background-image` on a `::before` pseudo-element is semantically correct: it carries no meaning for assistive technology and requires no `alt` text or ARIA attributes.
|
||||
|
||||
User-provided `alt` text on the main `<img>` is unaffected.
|
||||
|
||||
## Naming
|
||||
|
||||
| Platform | Attribute / Prop | Rationale |
|
||||
| --- | --- | --- |
|
||||
| HTML | `placeholdersrc` | Lowercase HTML attribute convention; matches Media Chrome |
|
||||
| React | `placeholder` | CamelCase React prop convention; matches Mux Player |
|
||||
@@ -18,6 +18,23 @@ const sharedSheet = createShadowStyle(sharedStyles);
|
||||
* via `adoptedStyleSheets` (or `<style>` fallback).
|
||||
*/
|
||||
export class SkinElement extends ReactiveElement {
|
||||
static get observedAttributes(): string[] {
|
||||
// biome-ignore lint/complexity/noThisInStatic: intentional use of super
|
||||
return [...super.observedAttributes, 'placeholdersrc'];
|
||||
}
|
||||
|
||||
override attributeChangedCallback(attr: string, oldValue: string | null, newValue: string | null): void {
|
||||
super.attributeChangedCallback(attr, oldValue, newValue);
|
||||
|
||||
if (attr === 'placeholdersrc') {
|
||||
if (newValue) {
|
||||
this.style.setProperty('--media-poster-placeholder', `url(${newValue})`);
|
||||
} else {
|
||||
this.style.removeProperty('--media-poster-placeholder');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static shadowRootOptions: ShadowRootInit = { mode: 'open' };
|
||||
static styles?: ShadowStyle;
|
||||
static template?: HTMLTemplateElement | null;
|
||||
|
||||
@@ -8,7 +8,24 @@ import { MediaUIElement } from '../media-ui-element';
|
||||
export class PosterElement extends MediaUIElement<PosterCore> {
|
||||
static readonly tagName = 'media-poster';
|
||||
|
||||
static get observedAttributes(): string[] {
|
||||
// biome-ignore lint/complexity/noThisInStatic: intentional use of super
|
||||
return [...super.observedAttributes, 'placeholdersrc'];
|
||||
}
|
||||
|
||||
protected readonly core = new PosterCore();
|
||||
protected readonly stateAttrMap = PosterDataAttrs;
|
||||
protected readonly mediaState = new PlayerController(this, playerContext, selectPlayback);
|
||||
|
||||
override attributeChangedCallback(attr: string, oldValue: string | null, newValue: string | null): void {
|
||||
super.attributeChangedCallback(attr, oldValue, newValue);
|
||||
|
||||
if (attr === 'placeholdersrc') {
|
||||
if (newValue) {
|
||||
this.style.setProperty('--media-poster-placeholder', `url(${newValue})`);
|
||||
} else {
|
||||
this.style.removeProperty('--media-poster-placeholder');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 }],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
.media-default-skin media-poster:not([data-visible]),
|
||||
.media-default-skin > img:not([data-visible]) {
|
||||
.media-default-skin > img:not([data-visible]),
|
||||
.media-default-skin > img[data-visible]:not([data-loaded]) {
|
||||
opacity: 0;
|
||||
}
|
||||
.media-default-skin media-poster ::slotted(img),
|
||||
@@ -31,6 +32,30 @@
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* Blurred placeholder: HTML path targets media-poster::before, React path targets the skin container::before */
|
||||
.media-default-skin media-poster::before,
|
||||
.media-default-skin::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
background-image: var(--media-poster-placeholder, none);
|
||||
background-repeat: no-repeat;
|
||||
background-position: var(--media-object-position, center);
|
||||
background-size: var(--media-object-fit, contain);
|
||||
filter: blur(var(--media-poster-placeholder-blur, 20px));
|
||||
}
|
||||
|
||||
/* React path: hidden by default, transitions in while poster loads */
|
||||
.media-default-skin::before {
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
/* Show placeholder while the poster is visible but not yet loaded */
|
||||
.media-default-skin:has(img[data-visible]:not([data-loaded]))::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.media-default-skin:fullscreen media-poster ::slotted(img),
|
||||
.media-default-skin:fullscreen media-poster img,
|
||||
.media-default-skin:fullscreen > img {
|
||||
|
||||
@@ -9,6 +9,13 @@ export const poster = (isShadowDOM: boolean) =>
|
||||
// In the shadow DOM, the class applies to the parent so we have to set styles on the slotted img.
|
||||
isShadowDOM
|
||||
? [
|
||||
// Placeholder (blur-up) — rides on media-poster opacity/transition
|
||||
'before:absolute before:inset-0 before:pointer-events-none',
|
||||
'before:[background-image:var(--media-poster-placeholder,none)]',
|
||||
'before:bg-no-repeat',
|
||||
'before:[background-position:var(--media-object-position,center)]',
|
||||
'before:[background-size:var(--media-object-fit,contain)]',
|
||||
'before:[filter:blur(var(--media-poster-placeholder-blur,20px))]',
|
||||
'[&_::slotted(img)]:absolute',
|
||||
'[&_::slotted(img)]:inset-0',
|
||||
'[&_::slotted(img)]:w-full',
|
||||
@@ -17,5 +24,9 @@ export const poster = (isShadowDOM: boolean) =>
|
||||
'[&_::slotted(img)]:[object-position:var(--media-object-position,center)]',
|
||||
'[&_::slotted(img)]:rounded-(--media-video-border-radius)',
|
||||
]
|
||||
: 'rounded-[inherit] [object-fit:var(--media-object-fit,contain)] [object-position:var(--media-object-position,center)]'
|
||||
: [
|
||||
'rounded-[inherit] [object-fit:var(--media-object-fit,contain)] [object-position:var(--media-object-position,center)]',
|
||||
// Hide until the image has loaded so the placeholder shows first
|
||||
'[&[data-visible]:not([data-loaded])]:opacity-0',
|
||||
]
|
||||
);
|
||||
|
||||
@@ -80,6 +80,19 @@ export const root = (isShadowDOM: boolean) =>
|
||||
'[&_video::-webkit-media-text-track-container]:font-[inherit]',
|
||||
]
|
||||
: [],
|
||||
// Poster placeholder (blur-up) — React path only; HTML path uses media-poster::before
|
||||
!isShadowDOM
|
||||
? [
|
||||
'before:absolute before:inset-0 before:pointer-events-none',
|
||||
'before:[background-image:var(--media-poster-placeholder,none)]',
|
||||
'before:bg-no-repeat',
|
||||
'before:[background-position:var(--media-object-position,center)]',
|
||||
'before:[background-size:var(--media-object-fit,contain)]',
|
||||
'before:opacity-0 before:[filter:blur(var(--media-poster-placeholder-blur,20px))]',
|
||||
'before:transition-opacity before:duration-250',
|
||||
'has-[img[data-visible]:not([data-loaded])]:before:opacity-100',
|
||||
]
|
||||
: [],
|
||||
// Fullscreen
|
||||
'[&:fullscreen]:[--media-border-radius:0]',
|
||||
{
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
.media-minimal-skin media-poster:not([data-visible]),
|
||||
.media-minimal-skin > img:not([data-visible]) {
|
||||
.media-minimal-skin > img:not([data-visible]),
|
||||
.media-minimal-skin > img[data-visible]:not([data-loaded]) {
|
||||
opacity: 0;
|
||||
}
|
||||
.media-minimal-skin media-poster ::slotted(img),
|
||||
@@ -31,6 +32,30 @@
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* Blurred placeholder: HTML path targets media-poster::before, React path targets the skin container::before */
|
||||
.media-minimal-skin media-poster::before,
|
||||
.media-minimal-skin::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
background-image: var(--media-poster-placeholder, none);
|
||||
background-repeat: no-repeat;
|
||||
background-position: var(--media-object-position, center);
|
||||
background-size: var(--media-object-fit, contain);
|
||||
filter: blur(var(--media-poster-placeholder-blur, 20px));
|
||||
}
|
||||
|
||||
/* React path: hidden by default, transitions in while poster loads */
|
||||
.media-minimal-skin::before {
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
/* Show placeholder while the poster is visible but not yet loaded */
|
||||
.media-minimal-skin:has(img[data-visible]:not([data-loaded]))::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.media-minimal-skin:fullscreen media-poster ::slotted(img),
|
||||
.media-minimal-skin:fullscreen media-poster img,
|
||||
.media-minimal-skin:fullscreen > img {
|
||||
|
||||
@@ -9,6 +9,13 @@ export const poster = (isShadowDOM: boolean) =>
|
||||
// In the shadow DOM, the class applies to the parent so we have to set styles on the slotted img.
|
||||
isShadowDOM
|
||||
? [
|
||||
// Placeholder (blur-up) — rides on media-poster opacity/transition
|
||||
'before:absolute before:inset-0 before:pointer-events-none',
|
||||
'before:[background-image:var(--media-poster-placeholder,none)]',
|
||||
'before:bg-no-repeat',
|
||||
'before:[background-position:var(--media-object-position,center)]',
|
||||
'before:[background-size:var(--media-object-fit,contain)]',
|
||||
'before:[filter:blur(var(--media-poster-placeholder-blur,20px))]',
|
||||
'[&_::slotted(img)]:absolute',
|
||||
'[&_::slotted(img)]:inset-0',
|
||||
'[&_::slotted(img)]:w-full',
|
||||
@@ -17,5 +24,9 @@ export const poster = (isShadowDOM: boolean) =>
|
||||
'[&_::slotted(img)]:[object-position:var(--media-object-position,center)]',
|
||||
'[&_::slotted(img)]:rounded-(--media-video-border-radius)',
|
||||
]
|
||||
: 'rounded-[inherit] [object-fit:var(--media-object-fit,contain)] [object-position:var(--media-object-position,center)]'
|
||||
: [
|
||||
'rounded-[inherit] [object-fit:var(--media-object-fit,contain)] [object-position:var(--media-object-position,center)]',
|
||||
// Hide until the image has loaded so the placeholder shows first
|
||||
'[&[data-visible]:not([data-loaded])]:opacity-0',
|
||||
]
|
||||
);
|
||||
|
||||
@@ -77,6 +77,19 @@ export const root = (isShadowDOM: boolean) =>
|
||||
'[&_video::-webkit-media-text-track-container]:font-[inherit]',
|
||||
]
|
||||
: [],
|
||||
// Poster placeholder (blur-up) — React path only; HTML path uses media-poster::before
|
||||
!isShadowDOM
|
||||
? [
|
||||
'before:absolute before:inset-0 before:pointer-events-none',
|
||||
'before:[background-image:var(--media-poster-placeholder,none)]',
|
||||
'before:bg-no-repeat',
|
||||
'before:[background-position:var(--media-object-position,center)]',
|
||||
'before:[background-size:var(--media-object-fit,contain)]',
|
||||
'before:opacity-0 before:[filter:blur(var(--media-poster-placeholder-blur,20px))]',
|
||||
'before:transition-opacity before:duration-250',
|
||||
'has-[img[data-visible]:not([data-loaded])]:before:opacity-100',
|
||||
]
|
||||
: [],
|
||||
// Fullscreen
|
||||
'[&:fullscreen]:[--media-border-radius:0]',
|
||||
{
|
||||
|
||||
@@ -147,7 +147,7 @@ const listFormat = new Intl.ListFormat('en', { style: 'long', type: 'unit' });
|
||||
<MarkdownCode class="inline-block whitespace-nowrap">{`<${skin.name}>`}</MarkdownCode>
|
||||
{skin.cssImport && (
|
||||
<div class="mt-0.5">
|
||||
<MarkdownCode class="text-code inline-block whitespace-nowrap">{`import '${skin.cssImport}';`}</MarkdownCode>
|
||||
<MarkdownCode class="inline-block text-code whitespace-nowrap">{`import '${skin.cssImport}';`}</MarkdownCode>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user