diff --git a/apps/sandbox/app/shared/react/use-placeholder.ts b/apps/sandbox/app/shared/react/use-placeholder.ts new file mode 100644 index 00000000..cee854cb --- /dev/null +++ b/apps/sandbox/app/shared/react/use-placeholder.ts @@ -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]); +} diff --git a/apps/sandbox/app/shared/sources.ts b/apps/sandbox/app/shared/sources.ts index a9e75d59..c3511b90 100644 --- a/apps/sandbox/app/shared/sources.ts +++ b/apps/sandbox/app/shared/sources.ts @@ -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; diff --git a/apps/sandbox/templates/html-mux-video/main.ts b/apps/sandbox/templates/html-mux-video/main.ts index ef28b9e5..257ac73c 100644 --- a/apps/sandbox/templates/html-mux-video/main.ts +++ b/apps/sandbox/templates/html-mux-video/main.ts @@ -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}"` : ''}> ${renderStoryboard(storyboard)} diff --git a/apps/sandbox/templates/react-mux-video/main.tsx b/apps/sandbox/templates/react-mux-video/main.tsx index 43e17fe7..b95b38d4 100644 --- a/apps/sandbox/templates/react-mux-video/main.tsx +++ b/apps/sandbox/templates/react-mux-video/main.tsx @@ -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() { +``` + +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 + + Video title + +``` + +`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 `` 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 `` 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 `` 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 | diff --git a/packages/html/src/define/skin-element.ts b/packages/html/src/define/skin-element.ts index 517f3125..d1a3f436 100644 --- a/packages/html/src/define/skin-element.ts +++ b/packages/html/src/define/skin-element.ts @@ -18,6 +18,23 @@ const sharedSheet = createShadowStyle(sharedStyles); * via `adoptedStyleSheets` (or `