Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/e2e/scripts/generate-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,8 @@ const Player = createPlayer({ features: videoFeatures });

function App() {
return (
<Player.Provider>
<VideoSkin${posterProp} style={{ maxWidth: 800, aspectRatio: '16/9' }}>
<Player.Provider${posterProp}>
<VideoSkin style={{ maxWidth: 800, aspectRatio: '16/9' }}>
<${reactMedia.component} src={MEDIA.${resource}.url} playsInline crossOrigin="anonymous">${storyboardTrack}
</${reactMedia.component}>
</VideoSkin>
Expand Down
8 changes: 0 additions & 8 deletions apps/sandbox/app/shared/react/use-placeholder.ts

This file was deleted.

8 changes: 8 additions & 0 deletions apps/sandbox/app/shared/react/use-poster-placeholder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { useMemo } from 'react';
import { getPosterPlaceholderImage } from '../sources';
import { useSource } from './use-source';

export function usePosterPlaceholder() {
const source = useSource();
return useMemo(() => getPosterPlaceholderImage(source), [source]);
}
8 changes: 6 additions & 2 deletions apps/sandbox/app/shared/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,9 +270,13 @@ export function getPosterSrc(source: SourceId): string | undefined {
return id ? `https://image.mux.com/${id}/thumbnail.webp${imageQuery(source, 'poster')}` : undefined;
}

export function getPlaceholderSrc(source: SourceId): string | undefined {
/** A 20px thumbnail, upscaled by the browser into a blur. */
export function getPosterPlaceholderImage(source: SourceId): string | undefined {
const id = getMuxAssetId(source);
return id ? `https://image.mux.com/${id}/thumbnail.webp${imageQuery(source, 'poster', 'width=20')}` : undefined;
if (!id) return undefined;

const url = `https://image.mux.com/${id}/thumbnail.webp${imageQuery(source, 'poster', 'width=20')}`;
return `url("${url}")`;
}

export function getStoryboardSrc(source: SourceId): string | undefined {
Expand Down
9 changes: 4 additions & 5 deletions apps/sandbox/templates/html-mux-video/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
onSkinChange,
onSourceChange,
} from '@app/shared/sandbox-listener';
import { getPlaceholderSrc, getPosterSrc, isLiveSource, SOURCES } from '@app/shared/sources';
import { getPosterPlaceholderImage, getPosterSrc, isLiveSource, SOURCES } from '@app/shared/sources';

const html = String.raw;

Expand All @@ -29,7 +29,7 @@ async function render() {
if (!tag) return;

const poster = getPosterSrc(state.source);
const placeholder = getPlaceholderSrc(state.source);
const posterPlaceholder = getPosterPlaceholderImage(state.source);
const mediaAttrs = renderMediaAttrs(state);
const playerTag = live ? 'live-video-player' : 'video-player';

Expand All @@ -39,14 +39,13 @@ async function render() {
const srcAttr = source ? '' : ` src="${url}"`;

document.getElementById('root')!.innerHTML = wrapSandboxHtmlI18n(html`
<${playerTag}>
<${tag} class="aspect-video max-w-4xl mx-auto"${placeholder ? ` placeholdersrc="${placeholder}"` : ''}>
<${playerTag}${poster ? ` poster="${poster}"` : ''}${posterPlaceholder ? ` poster-placeholder='${posterPlaceholder}'` : ''}>
<${tag} class="aspect-video max-w-4xl mx-auto">
<!-- The storyboard track is derived automatically from the Mux src. -->
<mux-video${srcAttr} ${mediaAttrs} playsinline crossorigin="anonymous"></mux-video>
<!-- Mux Data and Cast are opt-in media components; no env key is needed for Mux-hosted sources. -->
<mux-data player-software-name="mux-video"></mux-data>
<google-cast></google-cast>
${poster ? html`<img slot="poster" src="${poster}" alt="Video poster" />` : ''}
</${tag}>
</${playerTag}>
`);
Expand Down
10 changes: 2 additions & 8 deletions apps/sandbox/templates/react-hlsjs-video/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,8 @@ function App() {

return (
<SandboxI18nProvider>
<Provider>
<VideoSkinComponent
poster={poster}
skin={skin}
styling={styling}
live={live}
className="aspect-video max-w-4xl mx-auto"
>
<Provider poster={poster}>
<VideoSkinComponent skin={skin} styling={styling} live={live} className="aspect-video max-w-4xl mx-auto">
<HlsJsVideo
{...(hlsSource ? { source: hlsSource } : { src: url ?? '' })}
autoPlay={autoplay}
Expand Down
15 changes: 4 additions & 11 deletions apps/sandbox/templates/react-mux-video/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { VideoSkinComponent } from '@app/shared/react/skins';
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 { usePosterPlaceholder } from '@app/shared/react/use-poster-placeholder';
import { usePreload } from '@app/shared/react/use-preload';
import { useSkin } from '@app/shared/react/use-skin';
import { useSource } from '@app/shared/react/use-source';
Expand All @@ -27,7 +27,7 @@ function App() {
const source = useSource();
const styling = useMemo(readStyling, []);
const poster = usePoster();
const placeholder = usePlaceholder();
const posterPlaceholder = usePosterPlaceholder();
const live = isLiveSource(source);
const autoplay = useAutoplay();
const muted = useMuted();
Expand All @@ -41,15 +41,8 @@ function App() {

return (
<SandboxI18nProvider>
<Provider>
<VideoSkinComponent
poster={poster}
placeholder={placeholder}
skin={skin}
styling={styling}
live={live}
className="aspect-video max-w-4xl mx-auto"
>
<Provider poster={poster} posterPlaceholder={posterPlaceholder}>
<VideoSkinComponent skin={skin} styling={styling} live={live} className="aspect-video max-w-4xl mx-auto">
{/* The storyboard track is derived automatically from the Mux src. */}
<MuxVideo
{...(muxSource ? { source: muxSource } : { src: url ?? '' })}
Expand Down
10 changes: 2 additions & 8 deletions apps/sandbox/templates/react-native-hls-video/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,8 @@ function App() {

return (
<SandboxI18nProvider>
<Provider>
<VideoSkinComponent
poster={poster}
skin={skin}
styling={styling}
live={live}
className="w-full aspect-video max-w-4xl mx-auto"
>
<Provider poster={poster}>
<VideoSkinComponent skin={skin} styling={styling} live={live} className="w-full aspect-video max-w-4xl mx-auto">
<NativeHlsVideo
{...(hlsSource ? { source: hlsSource } : { src: url ?? '' })}
autoPlay={autoplay}
Expand Down
10 changes: 2 additions & 8 deletions apps/sandbox/templates/react-simple-hls-video/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,8 @@ function App() {

return (
<SandboxI18nProvider>
<Provider>
<VideoSkinComponent
poster={poster}
skin={skin}
styling={styling}
live={live}
className="aspect-video max-w-4xl mx-auto"
>
<Provider poster={poster}>
<VideoSkinComponent skin={skin} styling={styling} live={live} className="aspect-video max-w-4xl mx-auto">
<SimpleHlsVideo
src={SOURCES[source].url ?? ''}
autoPlay={autoplay}
Expand Down
4 changes: 2 additions & 2 deletions apps/sandbox/templates/react-video/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ function App() {

return (
<SandboxI18nProvider>
<VideoProvider>
<VideoSkinComponent poster={poster} skin={skin} styling={styling} className="aspect-video max-w-4xl mx-auto">
<VideoProvider poster={poster}>
<VideoSkinComponent skin={skin} styling={styling} className="aspect-video max-w-4xl mx-auto">
<Video
src={SOURCES[source].url}
autoPlay={autoplay}
Expand Down
10 changes: 7 additions & 3 deletions internal/design/ui/poster-placeholder.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,22 @@ Poster placeholders provide a low-resolution or first-frame visual while the fin
## Decisions

- Keep placeholder behavior on the poster component instead of introducing another public component with overlapping lifecycle and accessibility.
- Render placeholder and final poster as separate visual layers so the final image can crossfade without replacing the whole component.
- Resolve the value as player state on the metadata feature, on the same tiers as the poster, so it is reachable outside a skin and a media donor can supply one.
- Take a CSS image rather than a URL, and pass it through untouched. The store never has to decide how to quote a URL, and a gradient works for free.
- Keep offering it as `--media-poster-placeholder`, the name the skins already read, but set it from resolved state rather than from a skin attribute or prop.
- Offer the value rather than painting it. The packaged skins paint it as the poster image's own `background-image` — one element, since a replaced element paints a background behind its content — and a skin that wants a separate layer to crossfade can still have one.
- Let a consumer supply the placeholder directly; extraction of a first frame or image transformation is outside the UI component.
- Expose presentation through CSS custom properties so skins can align sizing, position, filtering, and transition behavior across both layers.
- Keep the placeholder decorative. The poster component owns any meaningful accessible name, preventing duplicate image announcements.

## Consequences

The same concept works in React and HTML and remains skinnable without a JavaScript animation API. Consumers are responsible for choosing a safe placeholder URL and for any media-frame generation policy.
The same mechanism works in React and HTML without a JavaScript animation API. Two costs follow from passing the value through. Quoting is the caller's, so a data URL carrying double quotes needs `url('…')` or the browser drops the declaration silently. And blur is the caller's too: the `::before` layer that applied `filter: blur(var(--media-poster-placeholder-blur, 20px))` is gone, and so is that variable. Deliberate — image pipelines already hand back a pre-blurred data URL, the way Next.js does.

## Current sources of truth

- Store: `packages/core/src/dom/store/features/metadata.ts`
- React implementation: `packages/react/src/ui/poster/poster.tsx`
- HTML implementation: `packages/html/src/ui/poster/poster-element.ts`
- Skins: `packages/skins/src/*/css/components/poster.css` and the Tailwind variant beside it
- Poster design context: [Poster](poster.md)
- Public API reference and package exports
23 changes: 17 additions & 6 deletions internal/design/ui/poster.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,24 @@ Display component for video poster image. Shows before playback starts, hides af

Video players show a poster image before playback. Existing solutions (Media Chrome, Vidstack) either manage the image internally via `src` prop or expose complex state (`data-loading`, `data-error`, `data-hidden`, `data-visible`).

We want a simpler approach: expose minimal state (`data-visible`), let the user control the image.
We want a simpler approach: keep the state small, let the user control the image.

## Solution

**HTML:** Wrapper element that accepts `<img>` as child.
**HTML:** A controller in the shape of `<picture>` — it renders no image and coordinates the one that is already its child.
**React:** Renders `<img>` directly — no wrapper needed.

The URL is player state on the metadata feature, not markup here, so a hand-authored layout resolves it the same way a skin does. Either binding fills in `src` only when the consumer supplied none — the [decision below](#component-managed-image-src-prop) still holds, so it is a default rather than a takeover. The packaged skins carry a plain `<img>` as poster-slot fallback content, which an `<img slot="poster">` displaces.

Visibility: `visible = !playback.started`. The poster shows until playback starts. `started` persists — pausing doesn't reset it.

`data-visible`, `data-loading`, `data-loaded`, and `data-error` are reported on `<media-poster>` rather than on the image, since the image may be the author's and a skin has no selector that reaches into it.

## Accessibility

**Wrapper (`<media-poster>`):** No ARIA role needed. Custom elements have no implicit role, so there's no semantics to hide or override. Do not add `aria-hidden` — the poster image may be informative.
**HTML element (`<media-poster>`):** No ARIA role needed. Custom elements have no implicit role, so there's no semantics to hide or override. Do not add `aria-hidden` — the poster image may be informative.

**Child (`<img>`):** User provides appropriate `alt` text. Whether a poster is informative or decorative is the author's judgment (per [WAI guidelines](https://www.w3.org/WAI/tutorials/images/decorative/)). This is an advantage over Media Chrome (which forces `aria-hidden="true"` on the internal image) and native `<video poster>` (which has no `alt` equivalent).
**Image:** The one each binding renders carries `alt=""`. A resolved URL says nothing about what it depicts, and announcing the URL is worse than announcing nothing. Supply your own `alt` — as a prop in React, on your own `<img>` in HTML — to describe a poster that carries meaning. Whether a poster is informative or decorative is the author's judgment (per [WAI guidelines](https://www.w3.org/WAI/tutorials/images/decorative/)). This is an advantage over Media Chrome (which forces `aria-hidden="true"` on the internal image) and native `<video poster>` (which has no `alt` equivalent).

## Alternatives Considered

Expand All @@ -42,8 +46,15 @@ Like Media Chrome — component owns the `<img>` internally.

Our approach makes the flexible path the default.

### An `<img>` the HTML element owns in its shadow root

Considered so `<media-poster>` alone would render something once the URL came from the store.

**Why not:** the owned image is stylable only through `::part(img)`, skins end up with two styling contracts instead of one, and two images sit in the tree with one hidden.

Two costs follow. `<media-poster>` renders nothing on its own, so a hand-authored layout supplies the image and a `__DEV__` warning fires when a poster resolves without one. And a skin reaches a slotted image through `::slotted(img)`, which matches only what is assigned directly — wrap one in a `<picture>` or a framework component and its sizing belongs to the author.

## Future

1. **`data-ended`** — Show poster when media ends.
2. **`data-loaded`** — Set when child image loads, enabling CSS-only placeholder-to-main transitions.
3. **Transition/animation support** — CSS transition recommendations for fade in/out.
2. **Transition/animation support** — CSS transition recommendations for fade in/out.
1 change: 1 addition & 0 deletions packages/core/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export * from './ui/popover/popover-css-vars';
export * from './ui/popover/popover-data-attrs';
export * from './ui/popover/popup-host-attr';
export * from './ui/poster/poster-core';
export * from './ui/poster/poster-css-vars';
export * from './ui/poster/poster-data-attrs';
export * from './ui/quality-radio-group/quality-radio-group-core';
export * from './ui/quality-radio-group/quality-radio-group-data-attrs';
Expand Down
35 changes: 32 additions & 3 deletions packages/core/src/core/ui/poster/poster-core.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,53 @@
import type { MediaPlaybackState } from '@videojs/media';
import type { MediaMetadataState, MediaPlaybackState } from '@videojs/media';

export type PosterMediaState = Pick<MediaPlaybackState, 'started'> &
Pick<MediaMetadataState, 'poster' | 'posterPlaceholder'>;

/** Reported by the binding, which holds the image. */
export type PosterImageStatus = 'none' | 'loading' | 'loaded' | 'error';

export interface PosterState {
visible: boolean;
/** Resolved poster URL, empty when nothing supplied one. */
src: string;
/**
* Resolved placeholder, empty when nothing supplied one.
*
* Reaches CSS as `--media-poster-placeholder` for a skin to paint.
*/
placeholder: string;
loading: boolean;
loaded: boolean;
error: boolean;
}

export class PosterCore {
#media: MediaPlaybackState | null = null;
#media: PosterMediaState | null = null;
#status: PosterImageStatus = 'none';

setMedia(media: MediaPlaybackState): void {
setMedia(media: PosterMediaState): void {
this.#media = media;
}

setImageStatus(status: PosterImageStatus): void {
this.#status = status;
}

getState(): PosterState {
const media = this.#media!;
return {
visible: !media.started,
src: media.poster,
placeholder: media.posterPlaceholder,
loading: this.#status === 'loading',
loaded: this.#status === 'loaded',
error: this.#status === 'error',
};
}
}

export namespace PosterCore {
export type State = PosterState;
export type MediaState = PosterMediaState;
export type ImageStatus = PosterImageStatus;
}
10 changes: 10 additions & 0 deletions packages/core/src/core/ui/poster/poster-css-vars.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** CSS custom property names for poster visual state. */
export const PosterCSSVars = {
/**
* The resolved poster placeholder, as a CSS image.
*
* It's easiest to pass to your poster's `background-image`, but you could also
* use this in a separate layer and try some cross-fade animations.
*/
placeholder: '--media-poster-placeholder',
} as const;
3 changes: 3 additions & 0 deletions packages/core/src/core/ui/poster/poster-data-attrs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ import type { PosterState } from './poster-core';

export const PosterDataAttrs = {
visible: 'data-visible',
loading: 'data-loading',
loaded: 'data-loaded',
error: 'data-error',
} as const satisfies StateAttrMap<PosterState>;
Loading
Loading