mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add poster component (#457)
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
---
|
||||
status: draft
|
||||
date: 2025-02-05
|
||||
---
|
||||
|
||||
# Poster
|
||||
|
||||
Display component for video poster image. Shows before playback starts, hides after.
|
||||
|
||||
## Problem
|
||||
|
||||
Video players show a poster image before playback. The poster:
|
||||
|
||||
1. Gives users a preview of the content
|
||||
2. Should hide once playback starts
|
||||
3. May optionally reappear when playback ends
|
||||
|
||||
Existing solutions (Media Chrome, Vidstack) either:
|
||||
|
||||
- Manage the image internally via `src` prop
|
||||
- Expose complex state (`data-loading`, `data-error`, `data-hidden`, `data-visible`)
|
||||
|
||||
We want a simpler approach: expose minimal state, let the user control the image.
|
||||
|
||||
## Solution
|
||||
|
||||
Minimal components that:
|
||||
|
||||
1. Expose `data-visible` for CSS-based show/hide
|
||||
2. Do nothing else
|
||||
|
||||
**HTML:** Wrapper element that accepts `<img>` as child.
|
||||
**React:** Renders `<img>` directly — no wrapper needed.
|
||||
|
||||
### Usage
|
||||
|
||||
#### HTML
|
||||
|
||||
```html
|
||||
<media-poster>
|
||||
<img src="poster.jpg" alt="Video description" />
|
||||
</media-poster>
|
||||
```
|
||||
|
||||
#### React
|
||||
|
||||
```tsx
|
||||
import { Poster } from '@videojs/react';
|
||||
|
||||
<Poster src="poster.jpg" alt="Video description" />;
|
||||
```
|
||||
|
||||
#### CSS
|
||||
|
||||
```css
|
||||
media-poster:not([data-visible]) {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
### With Responsive Image
|
||||
|
||||
#### HTML
|
||||
|
||||
```html
|
||||
<media-poster>
|
||||
<img
|
||||
src="poster.jpg"
|
||||
srcset="poster-480.jpg 480w, poster-720.jpg 720w"
|
||||
sizes="(max-width: 600px) 480px, 720px"
|
||||
alt="Video description"
|
||||
loading="lazy"
|
||||
/>
|
||||
</media-poster>
|
||||
```
|
||||
|
||||
#### React
|
||||
|
||||
```tsx
|
||||
<Poster
|
||||
src="poster.jpg"
|
||||
srcSet="poster-480.jpg 480w, poster-720.jpg 720w"
|
||||
sizes="(max-width: 600px) 480px, 720px"
|
||||
alt="Video description"
|
||||
loading="lazy"
|
||||
/>
|
||||
```
|
||||
|
||||
User controls the image entirely — responsive images, lazy loading, placeholder strategies all work naturally.
|
||||
|
||||
### With Placeholder (Blurhash / LQIP)
|
||||
|
||||
Use CSS `background-image` on the `<img>` element to show a placeholder while the main image loads. When the `src` loads, it naturally covers the background.
|
||||
|
||||
#### HTML
|
||||
|
||||
```html
|
||||
<media-poster>
|
||||
<img
|
||||
src="poster.jpg"
|
||||
alt="Video description"
|
||||
style="background: url(data:image/jpeg;base64,...) center/cover no-repeat;"
|
||||
/>
|
||||
</media-poster>
|
||||
```
|
||||
|
||||
#### React
|
||||
|
||||
```tsx
|
||||
<Poster
|
||||
src="poster.jpg"
|
||||
alt="Video description"
|
||||
style={{ background: 'url(data:image/jpeg;base64,...) center/cover no-repeat' }}
|
||||
/>
|
||||
```
|
||||
|
||||
For fade transitions (blur → sharp), handle the `load` event on the image:
|
||||
|
||||
```html
|
||||
<media-poster>
|
||||
<img
|
||||
src="poster.jpg"
|
||||
alt="Video description"
|
||||
style="background: url(blur.jpg) center/cover; opacity: 0; transition: opacity 0.3s;"
|
||||
onload="this.style.opacity = 1"
|
||||
/>
|
||||
</media-poster>
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Data Attributes
|
||||
|
||||
| Attribute | Description |
|
||||
| -------------- | -------------------------------------------- |
|
||||
| `data-visible` | Present when poster should show (`!started`) |
|
||||
|
||||
### Visibility Logic
|
||||
|
||||
```ts
|
||||
visible = !playback.started;
|
||||
```
|
||||
|
||||
The poster is visible until playback has started. Once `started` becomes `true` (user plays or seeks), the poster hides and stays hidden.
|
||||
|
||||
**Note:** `started` persists — pausing doesn't reset it. The poster only shows on initial load or after a new source is loaded.
|
||||
|
||||
## Styling Notes
|
||||
|
||||
The component sets no default styles. Recommended CSS:
|
||||
|
||||
```css
|
||||
media-poster {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
media-poster:not([data-visible]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
media-poster img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
```
|
||||
|
||||
**`pointer-events: none`** — Clicks should pass through to the video or play button beneath.
|
||||
|
||||
## 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` to the wrapper — the poster image may be informative.
|
||||
|
||||
**Child (`<img>`):** User provides appropriate `alt` text describing the poster content (e.g., `alt="Keynote speaker at a conference"`). If purely decorative, use `alt=""`.
|
||||
|
||||
Whether a poster is informative or decorative is the author's judgment (per [WAI guidelines](https://www.w3.org/WAI/tutorials/images/decorative/)). In most video player contexts, posters are informative — they give users a preview of the video content. 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
|
||||
|
||||
### Raw state attributes (`data-started`, `data-ended`)
|
||||
|
||||
Expose underlying state, let users compose visibility in CSS.
|
||||
|
||||
```css
|
||||
media-poster[data-started]:not([data-ended]) {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
**Why not:** Requires users to understand the state model. `data-started` on a poster doesn't make sense in the component's local context — `data-visible` directly describes the poster's state. Consistent with how button components use context-appropriate names (`data-fullscreen`, `data-muted`) rather than raw feature state.
|
||||
|
||||
**Future:** Could add `data-started` and `data-ended` later if needed for advanced use cases (e.g., show poster on ended).
|
||||
|
||||
### Component-managed image (`src` prop)
|
||||
|
||||
Like Media Chrome — component owns the `<img>` internally.
|
||||
|
||||
**Why not:** Limits user control. Can't use `srcset`, `loading="lazy"`, `<picture>`, or framework-specific optimized image components (Next.js `<Image>`, Astro `<Image>`). Media Chrome acknowledges this tradeoff in their docs: "If better control or better performance is desired, you can use `<img slot="poster" src="...">` instead."
|
||||
|
||||
Our approach makes the flexible path the default.
|
||||
|
||||
## Future
|
||||
|
||||
1. **`data-ended`** — Show poster when media ends. Would allow `media-poster[data-visible]:not([data-ended])` patterns.
|
||||
2. **`data-loaded`** — Set when child image loads. Enables CSS-only placeholder-to-main transitions without user-handled `onload`. Lightweight to implement (listen for `load` event on child `<img>`).
|
||||
3. **Transition/animation support** — CSS transition recommendations for fade in/out.
|
||||
@@ -5,6 +5,8 @@ export * from './ui/mute-button/mute-button-core';
|
||||
export * from './ui/mute-button/mute-button-data-attrs';
|
||||
export * from './ui/play-button/play-button-core';
|
||||
export * from './ui/play-button/play-button-data-attrs';
|
||||
export * from './ui/poster/poster-core';
|
||||
export * from './ui/poster/poster-data-attrs';
|
||||
export * from './ui/time/time-core';
|
||||
export * from './ui/time/time-data-attrs';
|
||||
export * from './ui/types';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { MediaPlaybackState } from '../../media/state';
|
||||
|
||||
export interface PosterState {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export class PosterCore {
|
||||
getState(media: MediaPlaybackState): PosterState {
|
||||
return {
|
||||
visible: !media.started,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export namespace PosterCore {
|
||||
export type State = PosterState;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { StateAttrMap } from '../types';
|
||||
import type { PosterState } from './poster-core';
|
||||
|
||||
export const PosterDataAttrs = {
|
||||
visible: 'data-visible',
|
||||
} as const satisfies StateAttrMap<PosterState>;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MediaPlaybackState } from '../../../media/state';
|
||||
import { PosterCore } from '../poster-core';
|
||||
|
||||
function createMediaState(overrides: Partial<MediaPlaybackState> = {}): MediaPlaybackState {
|
||||
return {
|
||||
paused: true,
|
||||
ended: false,
|
||||
started: false,
|
||||
waiting: false,
|
||||
play: vi.fn(async () => {}),
|
||||
pause: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PosterCore', () => {
|
||||
describe('getState', () => {
|
||||
it('returns visible: true when playback has not started', () => {
|
||||
const core = new PosterCore();
|
||||
const media = createMediaState({ started: false });
|
||||
|
||||
const state = core.getState(media);
|
||||
|
||||
expect(state.visible).toBe(true);
|
||||
});
|
||||
|
||||
it('returns visible: false when playback has started', () => {
|
||||
const core = new PosterCore();
|
||||
const media = createMediaState({ started: true });
|
||||
|
||||
const state = core.getState(media);
|
||||
|
||||
expect(state.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('returns only primitive values (no methods)', () => {
|
||||
const core = new PosterCore();
|
||||
const media = createMediaState();
|
||||
|
||||
const state = core.getState(media);
|
||||
|
||||
expect(state).toEqual({ visible: true });
|
||||
|
||||
const functionKeys = Object.entries(state).filter(([, value]) => typeof value === 'function');
|
||||
expect(functionKeys).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('visibility is independent of paused state', () => {
|
||||
const core = new PosterCore();
|
||||
|
||||
// Started but paused - should not be visible
|
||||
expect(core.getState(createMediaState({ started: true, paused: true })).visible).toBe(false);
|
||||
|
||||
// Started and playing - should not be visible
|
||||
expect(core.getState(createMediaState({ started: true, paused: false })).visible).toBe(false);
|
||||
|
||||
// Not started and paused - should be visible
|
||||
expect(core.getState(createMediaState({ started: false, paused: true })).visible).toBe(true);
|
||||
});
|
||||
|
||||
it('visibility is independent of ended state', () => {
|
||||
const core = new PosterCore();
|
||||
|
||||
// Started and ended - should not be visible (started takes precedence)
|
||||
expect(core.getState(createMediaState({ started: true, ended: true })).visible).toBe(false);
|
||||
|
||||
// Not started and ended (edge case) - should be visible
|
||||
expect(core.getState(createMediaState({ started: false, ended: true })).visible).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PosterElement } from '../../ui/poster/poster-element';
|
||||
|
||||
customElements.define(PosterElement.tagName, PosterElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[PosterElement.tagName]: PosterElement;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export { FullscreenButtonElement } from './ui/fullscreen-button/fullscreen-butto
|
||||
export * from './ui/media-element';
|
||||
export { MuteButtonElement } from './ui/mute-button/mute-button-element';
|
||||
export { PlayButtonElement } from './ui/play-button/play-button-element';
|
||||
export { PosterElement } from './ui/poster/poster-element';
|
||||
export { TimeElement } from './ui/time/time-element';
|
||||
export { TimeGroupElement } from './ui/time/time-group-element';
|
||||
export { TimeSeparatorElement } from './ui/time/time-separator-element';
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { PropertyValues } from '@lit/reactive-element';
|
||||
import { PosterCore, PosterDataAttrs } from '@videojs/core';
|
||||
import { applyStateDataAttrs, logMissingFeature, selectPlayback } from '@videojs/core/dom';
|
||||
|
||||
import { playerContext } from '../../player/context';
|
||||
import { PlayerController } from '../../player/player-controller';
|
||||
import { MediaElement } from '../media-element';
|
||||
|
||||
export class PosterElement extends MediaElement {
|
||||
static readonly tagName = 'media-poster';
|
||||
|
||||
readonly #core = new PosterCore();
|
||||
readonly #state = new PlayerController(this, playerContext, selectPlayback);
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
if (__DEV__ && !this.#state.value) {
|
||||
logMissingFeature(PosterElement.tagName, 'playback');
|
||||
}
|
||||
}
|
||||
|
||||
protected override update(changed: PropertyValues): void {
|
||||
super.update(changed);
|
||||
|
||||
const media = this.#state.value;
|
||||
|
||||
if (!media) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyStateDataAttrs(this, this.#core.getState(media), PosterDataAttrs);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export { FullscreenButton, type FullscreenButtonProps } from './ui/fullscreen-bu
|
||||
export { useButton } from './ui/hooks/use-button';
|
||||
export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button';
|
||||
export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button';
|
||||
export { Poster, type PosterProps } from './ui/poster/poster';
|
||||
export { Time } from './ui/time';
|
||||
|
||||
// Utilities
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { PosterCore, PosterDataAttrs } from '@videojs/core';
|
||||
import { logMissingFeature, selectPlayback } from '@videojs/core/dom';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef, useState } from 'react';
|
||||
|
||||
import { usePlayer } from '../../player/context';
|
||||
import type { UIComponentProps } from '../../utils/types';
|
||||
import { renderElement } from '../../utils/use-render';
|
||||
|
||||
export interface PosterProps extends UIComponentProps<'img', PosterCore.State> {}
|
||||
|
||||
/**
|
||||
* Displays the video poster image. Shows before playback starts, hides after.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Poster src="poster.jpg" alt="Video description" />
|
||||
*
|
||||
* <Poster
|
||||
* src="poster.jpg"
|
||||
* alt="Video description"
|
||||
* className={(state) => state.visible ? 'visible' : 'hidden'}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const Poster = forwardRef(function Poster(
|
||||
componentProps: PosterProps,
|
||||
forwardedRef: ForwardedRef<HTMLImageElement>
|
||||
) {
|
||||
const { render, className, style, ...elementProps } = componentProps;
|
||||
|
||||
const playback = usePlayer(selectPlayback);
|
||||
|
||||
const [core] = useState(() => new PosterCore());
|
||||
|
||||
if (!playback) {
|
||||
if (__DEV__) logMissingFeature('Poster', 'playback');
|
||||
return null;
|
||||
}
|
||||
|
||||
return renderElement(
|
||||
'img',
|
||||
{ render, className, style },
|
||||
{
|
||||
state: core.getState(playback),
|
||||
stateAttrMap: PosterDataAttrs,
|
||||
ref: [forwardedRef],
|
||||
props: [elementProps],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export namespace Poster {
|
||||
export type Props = PosterProps;
|
||||
export type State = PosterCore.State;
|
||||
}
|
||||
Reference in New Issue
Block a user