Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion apps/e2e/shared/fixtures/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export const SELECTORS = {
'time[data-type="remaining"]',
].join(', '),
timeToggle: 'media-time[toggle], time[role="button"][data-type]',
poster: 'media-poster, img[data-loaded]',
poster: 'media-poster, .media-poster',
bufferingIndicator: 'media-buffering-indicator, .media-buffering-indicator',
thumbnail: 'media-slider-thumbnail, .media-slider-thumbnail-image, [role="img"]:has(> img[aria-hidden="true"])',

Expand Down
10 changes: 5 additions & 5 deletions apps/e2e/suites/player/tests/skin-container.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ const SOURCE_SKINS = [

const POSTER_SKINS = [
{ framework: 'packaged HTML', path: '/pages/html-video-mp4.html', selector: 'img[slot="poster"]' },
{ framework: 'packaged React', path: '/pages/react-video-mp4.html', selector: '.media-skin > img' },
{ framework: 'packaged React', path: '/pages/react-video-mp4.html', selector: '.media-poster img' },
{ framework: 'VJSC HTML', path: '/pages/source-html-video-mp4.html', selector: 'media-poster img' },
{ framework: 'VJSC React', path: '/pages/source-react-video-mp4.html', selector: 'img.media-poster' },
{ framework: 'VJSC React', path: '/pages/source-react-video-mp4.html', selector: '.media-poster img' },
] as const;

for (const { framework, path } of SOURCE_SKINS) {
Expand All @@ -21,19 +21,19 @@ for (const { framework, path } of SOURCE_SKINS) {

test('renders media and poster in one container composition', async ({ page }) => {
const skin = page.locator('[data-source-skin]');
const posterImage = skin.locator('media-poster img, img.media-poster').first();
const posterImage = skin.locator('media-poster img, .media-poster img').first();

await expect(skin).toBeAttached();
await expect(page.locator('video')).toBeAttached();
await expect(skin.locator('media-poster, img.media-poster')).toBeAttached();
await expect(skin.locator('media-poster, .media-poster')).toBeAttached();
await expect(skin.locator('media-controls-content, .video-controls')).toBeAttached();
await expect(skin.locator('media-controls-backdrop, .video-controls-backdrop')).toBeAttached();
await expect(skin.locator('media-seek-button, .media-seek-button')).toHaveCount(0);
await expect(posterImage).toHaveAttribute('src', /thumbnail/);
});

test('hides the poster once playback starts', async ({ page }) => {
const poster = page.locator('media-poster, img.media-poster').first();
const poster = page.locator('media-poster, .media-poster').first();

await expect(poster).toHaveAttribute('data-visible', '');
await expect(poster).toHaveCSS('opacity', '1');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,7 @@ async function preparePanel({ root }: SkinPanel, width: number) {
await expect(root).toHaveAttribute('data-controls-visible', '');
await expect(root.getByRole('button', { name: 'Play' })).toBeVisible();

const poster = root.locator('img[data-loaded], media-poster[data-loaded]').first();
const poster = root.locator('.media-poster[data-loaded], media-poster[data-loaded]').first();

await expect(poster).toBeVisible();
await expect(poster).toHaveCSS('opacity', '1');
Expand Down Expand Up @@ -1525,7 +1525,7 @@ async function sharedMotionContract(root: Locator) {
});
const controls = root.locator(CONTROLS_SELECTOR).first();
const button = root.getByRole('button', { name: 'Play', exact: true });
const poster = root.locator(':scope > .media-poster, :scope > img, :scope > media-poster').first();
const poster = root.locator(':scope > .media-poster, :scope > media-poster').first();
const settingsIcon = root.getByRole('button', { name: 'Settings', exact: true }).locator('svg, media-icon').first();
const playIconCandidates = await root
.getByRole('button', { name: 'Play', exact: true })
Expand Down Expand Up @@ -1872,7 +1872,7 @@ async function reducedMotionContract(root: Locator, menu: Locator, tooltipDurati
})
)
);
const poster = root.locator(':scope > .media-poster, :scope > img, :scope > media-poster').first();
const poster = root.locator(':scope > .media-poster, :scope > media-poster').first();
const settingsIcon = root.getByRole('button', { name: 'Settings', exact: true }).locator('svg, media-icon').first();
const seekThumb = root.getByRole('slider', { name: 'Seek' });
const seekSlider = seekThumb.locator('..');
Expand Down Expand Up @@ -1986,7 +1986,7 @@ async function layoutContract(root: Locator) {

return {
root: inspect(element),
poster: inspect(query('img[data-loaded], media-poster[data-loaded]'), { includeRadius: false }),
poster: inspect(query('.media-poster[data-loaded], media-poster[data-loaded]'), { includeRadius: false }),
controls: inspect(query(controlsSelector), { includeGap: false }),
primary: inspect(play?.parentElement ?? null, { includeGap: false }),
timeline: inspect(seek?.parentElement?.parentElement ?? null, {
Expand Down Expand Up @@ -2469,7 +2469,7 @@ async function skinContract(root: Locator) {
const mute = element.querySelector<HTMLElement>('[role="button"][aria-label="Mute"]');
const seek = element.querySelector<HTMLElement>('[role="slider"][aria-label="Seek"]');
const controls = play?.closest<HTMLElement>('[data-interactive], media-controls');
const poster = element.querySelector<HTMLElement>('img[data-visible], .media-poster, media-poster');
const poster = element.querySelector<HTMLElement>('.media-poster, media-poster');

const inspect = (target: HTMLElement | null | undefined) => {
if (!target) return null;
Expand Down
4 changes: 4 additions & 0 deletions apps/sandbox/app/shared/html/registry-skins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ export function defineTemplateSkin(tagName: string, source: SkinTemplate): strin

if (poster instanceof HTMLImageElement) {
poster.removeAttribute('slot');

// The template's image carries the skin's image class, which a slotted poster takes over.
if (posterTarget) poster.classList.add(...posterTarget.classList);

posterTarget?.replaceWith(poster);
} else if (posterTarget instanceof HTMLSlotElement) {
posterTarget.replaceWith(...posterTarget.childNodes);
Expand Down
16 changes: 8 additions & 8 deletions internal/design/ui/poster.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,20 @@ We want a simpler approach: keep the state small, let the user control the image

## Solution

**HTML:** A controller that renders no image and sets `src` on the one that is already its child. `<picture>` is the loose precedent for the shape, though sourcing runs the other way around: `<picture>` treats the `src` on its `<img>` as the fallback, while here an image with no source of its own is the one we fill in.
**React:** Renders `<img>` directly — no wrapper needed.
**HTML:** A controller that sets `src` on the image that is its child, drawing a fallback `<img part="image">` in its shadow root only while no child image is supplied. `<picture>` is the loose precedent for the shape, though sourcing runs the other way around: `<picture>` treats the `src` on its `<img>` as the fallback, while here an image with no source of its own is the one we fill in.
**React:** A compound component with a `Poster.Root` presentation boundary and one `Poster.Image` that supplies the image lifecycle. The root accepts other children so loading previews and overlays can respond to the same state without replacing the controlled image.

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.
`data-visible`, `data-loading`, `data-loaded`, and `data-error` are reported on `<media-poster>` and `Poster.Root` rather than on the image. The image may be the author's, and presentation layers around it need the same lifecycle state.

## Accessibility

**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.
**Root (`<media-poster>` or the `div` rendered by `Poster.Root`):** No ARIA role is needed. Do not add `aria-hidden` — the poster image may be informative.

**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).
**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` — on `Poster.Image` in React, or 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 @@ -46,13 +46,13 @@ 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
### An `<img>` the HTML element always 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.
**Why not as the only image:** an owned image is stylable only through `::part(image)`, so skins would end up with two styling contracts instead of one, and an image the author supplies would sit beside a hidden one.

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.
**What we do instead:** the element draws its `<img part="image">` only while no child image is composed, and removes it the moment one arrives. A hand-authored `<media-poster>` renders on its own, while the packaged skins keep supplying a plain `<img>` as poster-slot fallback content so their styling stays class-based. 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

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/core/ui/poster/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ export interface PosterState {
error: boolean;
}

/** Framework-neutral poster component props. */
export interface PosterProps {
/** Framework-neutral poster image props. */
export interface PosterImageProps {
src?: string | undefined;
}

Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/core/ui/poster/poster-component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { defineComponent } from 'vjsc/components';

import type { PosterProps } from './core';
import type { PosterImageProps } from './core';
import { PosterDataAttrs } from './data';

export default defineComponent<PosterProps>({ name: 'Poster' });
export default defineComponent({
name: 'Poster',
root: 'Root',
parts: {
Root: defineComponent(),
Image: defineComponent<PosterImageProps>(),
},
dataAttrs: PosterDataAttrs,
});
68 changes: 50 additions & 18 deletions packages/html/src/ui/poster/poster-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { UIElement } from '../ui-element';

const SHADOW_CSS = `\
:host {
display: block;
}
img {
display: block;
width: 100%;
height: 100%;
object-fit: var(--media-object-fit, contain);
object-position: var(--media-object-position, center);
}
img:not([src]) {
visibility: hidden;
}`;

/** What an element composes: whatever fills a slot, or the element's own children. */
function composedChildren(element: Element): Element[] {
if (element instanceof HTMLSlotElement) {
Expand Down Expand Up @@ -52,6 +67,20 @@ function hasOwnSource(img: HTMLImageElement): boolean {
return !!img.getAttribute('src') || img.hasAttribute('srcset');
}

/**
* The image the element draws when none is supplied, reachable from outside as `::part(image)`. Decorative by default,
* like the one each skin carries: a resolved URL says nothing about what it depicts.
*/
function createFallbackImage(): HTMLImageElement {
const img = document.createElement('img');

img.alt = '';
img.setAttribute('part', 'image');
img.setAttribute('decoding', 'async');

return img;
}

/** How the current source is faring. */
type ImageLoadState = 'pending' | 'loaded' | 'error';

Expand All @@ -62,13 +91,16 @@ type ImageLoadState = 'pending' | 'loaded' | 'error';
* `<img>` as the fallback, while here an image with no source of its own is the one this element fills in. Give the
* child a `src`, a `srcset`, or `<source>` candidates and it is yours, left alone.
*
* Renders no image of its own, so include one: `<media-poster><img alt=""></media-poster>`. Inside a skin, an `<img
* slot="poster">` of yours replaces the one the skin carries.
* Left empty, the element draws an image of its own in its shadow root. Supply one as a child to describe it or wrap
* it: `<media-poster><img alt="Keynote speaker"></media-poster>`. Inside a skin, an `<img slot="poster">` of yours
* replaces the one the skin carries.
*/
export class PosterElement extends UIElement {
static readonly tagName = 'media-poster';

readonly #core = new PosterCore();
readonly #shadow = this.attachShadow({ mode: 'open' });
readonly #fallback = createFallbackImage();
readonly #children = new MutationObserver(() => this.requestUpdate());

readonly #playback = new PlayerController(this, playerContext, selectPlayback);
Expand All @@ -81,7 +113,15 @@ export class PosterElement extends UIElement {

#imageEvents: AbortController | null = null;
#disconnect: AbortController | null = null;
#warnedMissingImage = false;

constructor() {
super();

const style = document.createElement('style');

style.textContent = SHADOW_CSS;
this.#shadow.append(style, document.createElement('slot'), this.#fallback);
}

override connectedCallback(): void {
super.connectedCallback();
Expand Down Expand Up @@ -131,7 +171,7 @@ export class PosterElement extends UIElement {

const { src } = this.#core.getState();

this.#adopt(findImage(this));
this.#adopt(findImage(this) ?? this.#fallback);
this.#applySource(src);

this.#core.setImageLoadState(this.#loadState);
Expand All @@ -155,6 +195,11 @@ export class PosterElement extends UIElement {
this.#owned = next !== null && !hasSource(next);
this.#imageLoadState = 'pending';

// The fallback only occupies the shadow root while it is the active image,
// so a supplied image never sits beside a hidden one.
if (next === this.#fallback) this.#shadow.append(this.#fallback);
else if (next) this.#fallback.remove();

if (!next) return;

// An image that finished before we started listening never fires again, so
Expand All @@ -176,20 +221,7 @@ export class PosterElement extends UIElement {

#applySource(src: string): void {
const img = this.#image;

if (!img) {
if (__DEV__ && src && !this.#warnedMissingImage) {
this.#warnedMissingImage = true;
console.warn(
`<${this.localName}> resolved a poster but has no image to put it in. ` +
`Add one as a child: <${this.localName}><img alt=""></${this.localName}>`
);
}

return;
}

if (!this.#owned) return;
if (!img || !this.#owned) return;

if (!src) {
this.#imageLoadState = 'pending';
Expand Down
66 changes: 39 additions & 27 deletions packages/html/src/ui/poster/tests/poster-element.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,37 @@ afterEach(() => {
});

describe('PosterElement', () => {
it('renders nothing of its own, working on the light DOM', async () => {
const { poster } = await mount();
it('sets its fallback image aside while a skin supplies one', async () => {
const { poster, skinImage, setPoster } = await mount();

await setPoster('poster.jpg');

expect(poster.shadowRoot!.querySelector('img')).toBeNull();
expect(skinImage.getAttribute('src')).toBe('poster.jpg');
});

it('fills a fallback image of its own when none is supplied', async () => {
ensureDefined(TestProviderElement);
ensureDefined(PosterElement);

const provider = document.createElement(TestProviderElement.tagName) as TestProviderElement;

expect(poster.shadowRoot).toBeNull();
provider.innerHTML = `<${PosterElement.tagName}></${PosterElement.tagName}>`;
document.body.appendChild(provider);

const poster = provider.querySelector(PosterElement.tagName) as PosterElement;

provider.store.attach({ media: document.createElement('video'), container: null });
setUserPoster(provider.store, 'poster.jpg');

const fallback = poster.shadowRoot!.querySelector('img')!;

// The player context resolves a tick after connect.
await vi.waitFor(() => expect(fallback.getAttribute('src')).toBe('poster.jpg'));

expect(fallback.getAttribute('part')).toBe('image');
expect(fallback.getAttribute('alt')).toBe('');
expect(poster.hasAttribute('data-loading')).toBe(true);
});

it('picks up a plain child image, including one added after mount', async () => {
Expand All @@ -140,13 +167,22 @@ describe('PosterElement', () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await poster.updateComplete;

const fallback = poster.shadowRoot!.querySelector('img')!;

expect(fallback.getAttribute('src')).toBe('poster.jpg');

// No slot is involved here, so nothing announces the image arriving.
const image = document.createElement('img');

image.alt = '';
poster.appendChild(image);

await vi.waitFor(() => expect(image.getAttribute('src')).toBe('poster.jpg'));

// The fallback steps out of the tree rather than sitting hidden beside
// the supplied image, and stops downloading what it was pointed at.
expect(fallback.isConnected).toBe(false);
expect(fallback.hasAttribute('src')).toBe(false);
});

it('fills the source of the image the skin supplied', async () => {
Expand Down Expand Up @@ -370,28 +406,4 @@ describe('PosterElement', () => {
expect(poster.hasAttribute('data-loaded')).toBe(false);
expect(poster.hasAttribute('data-loading')).toBe(true);
});

it('warns when a poster resolves with no image to put it in', async () => {
ensureDefined(TestProviderElement);
ensureDefined(PosterElement);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

const provider = document.createElement(TestProviderElement.tagName) as TestProviderElement;

document.body.appendChild(provider);

const poster = document.createElement(PosterElement.tagName) as PosterElement;

provider.appendChild(poster);
provider.store.attach({ media: document.createElement('video'), container: null });
await poster.updateComplete;

setUserPoster(provider.store, 'poster.jpg');
poster.requestUpdate();
await poster.updateComplete;

expect(warn).toHaveBeenCalledWith(expect.stringContaining('no image to put it in'));

warn.mockRestore();
});
});
Loading
Loading