Skip to content
Closed
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
7 changes: 7 additions & 0 deletions packages/react/src/components/ui/poster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,23 @@ interface PosterImgProps {
const PosterImg = React.forwardRef<HTMLImageElement, PosterImgProps>(
({ instance, children, ...props }, forwardRef) => {
const { src, img, alt, crossOrigin, hidden } = instance.$state,
{ loading, decoding, fetchPriority } = instance.$props,
$src = useSignal(src),
$alt = useSignal(alt),
$crossOrigin = useSignal(crossOrigin),
$loading = useSignal(loading),
$decoding = useSignal(decoding),
$fetchPriority = useSignal(fetchPriority),
$hidden = useSignal(hidden);
return (
<Primitive.img
{...props}
src={$src || undefined}
alt={$alt || undefined}
crossOrigin={$crossOrigin || undefined}
loading={$loading || undefined}
decoding={$decoding || undefined}
fetchPriority={$fetchPriority || undefined}
ref={composeRefs(img.set as any, forwardRef)}
style={{ display: $hidden ? 'none' : undefined }}
>
Expand Down
25 changes: 25 additions & 0 deletions packages/vidstack/src/components/ui/poster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { useMediaContext, type MediaContext } from '../../core/api/media-context
import type { MediaCrossOrigin } from '../../core/api/types';
import { preconnect } from '../../utils/network';

export type PosterImageLoading = 'eager' | 'lazy';
export type PosterImageDecoding = 'async' | 'auto' | 'sync';
export type PosterImageFetchPriority = 'auto' | 'high' | 'low';

export interface PosterProps {
/**
* The URL of the poster image resource.
Expand All @@ -22,6 +26,24 @@ export interface PosterProps {
* @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin}
*/
crossOrigin: true | MediaCrossOrigin | null;
/**
* Indicates how the browser should load the poster image.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/loading}
*/
loading: PosterImageLoading | null;
/**
* Provides a hint to the browser for how the poster image should be decoded.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/decoding}
*/
decoding: PosterImageDecoding | null;
/**
* Indicates the relative priority to use when fetching the poster image.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/fetchPriority}
*/
fetchPriority: PosterImageFetchPriority | null;
}

export interface PosterState {
Expand Down Expand Up @@ -49,6 +71,9 @@ export class Poster extends Component<PosterProps, PosterState> {
src: null,
alt: null,
crossOrigin: null,
loading: null,
decoding: null,
fetchPriority: null,
};

static state = new State<PosterState>({
Expand Down
97 changes: 97 additions & 0 deletions packages/vidstack/src/elements/define/poster-element.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { MediaPlayerElement } from './player-element';
import { MediaPosterElement } from './poster-element';

describe('MediaPosterElement', () => {
beforeAll(() => {
stubBrowserAPIs();
defineElement(MediaPlayerElement);
defineElement(MediaPosterElement);
});

afterEach(() => {
document.body.innerHTML = '';
});

it('forwards image loading attributes to the default image', async () => {
const { poster } = setupPoster();

poster.setAttribute('src', '/poster.png');
poster.setAttribute('loading', 'lazy');
poster.setAttribute('decoding', 'async');
poster.setAttribute('fetchpriority', 'high');

await waitForUpdates();

const img = poster.querySelector('img');

expect(img).toBeInstanceOf(HTMLImageElement);
expect(img?.getAttribute('loading')).to.equal('lazy');
expect(img?.getAttribute('decoding')).to.equal('async');
expect(img?.getAttribute('fetchpriority')).to.equal('high');
});

it('uses an explicit image child instead of injecting a second image', async () => {
const { poster } = setupPoster();
const img = document.createElement('img');

img.setAttribute('src', '/poster.png');
img.setAttribute('loading', 'eager');
img.setAttribute('fetchpriority', 'high');
poster.append(img);

await waitForUpdates();

expect(poster.querySelectorAll('img')).to.have.length(1);
expect(poster.firstElementChild).to.equal(img);
expect(img.getAttribute('src')).to.equal('/poster.png');
expect(img.getAttribute('loading')).to.equal('eager');
expect(img.getAttribute('fetchpriority')).to.equal('high');
});
});

function stubBrowserAPIs() {
window.matchMedia ??= vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));

globalThis.IntersectionObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as any;

globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as any;
}

function defineElement(element: CustomElementConstructor & { tagName: string }) {
if (!customElements.get(element.tagName)) {
customElements.define(element.tagName, element);
}
}

function setupPoster() {
const player = document.createElement('media-player'),
poster = document.createElement('media-poster');

player.append(poster);
document.body.append(player);

return { player, poster };
}

async function waitForUpdates() {
await Promise.resolve();
await new Promise<void>((resolve) => setTimeout(resolve));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
95 changes: 86 additions & 9 deletions packages/vidstack/src/elements/define/poster-element.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import { effect } from 'maverick.js';
import { effect, onDispose } from 'maverick.js';
import { Host, type Attributes } from 'maverick.js/element';
import { setAttribute } from 'maverick.js/std';

import { Poster, type PosterProps } from '../../components/ui/poster';

const managedImgAttrs = [
'alt',
'crossorigin',
'src',
'loading',
'decoding',
'fetchpriority',
] as const;

type ManagedImgAttr = (typeof managedImgAttrs)[number];

/**
* @docs {@link https://www.vidstack.io/docs/wc/player/components/display/poster}
* @example
Expand All @@ -18,31 +29,97 @@ export class MediaPosterElement extends Host(HTMLElement, Poster) {

static override attrs: Attributes<PosterProps> = {
crossOrigin: 'crossorigin',
fetchPriority: 'fetchpriority',
};

#img = document.createElement('img');
#defaultImg = document.createElement('img');
#customImgAttrs = new WeakMap<HTMLImageElement, Map<ManagedImgAttr, string>>();

protected onSetup(): void {
this.$state.img.set(this.#img);
this.#updateImg();
}

protected onConnect(): void {
const { src, alt, crossOrigin } = this.$state;
const { loading, decoding, fetchPriority } = this.$props;

this.#updateImg();

const mutations = new MutationObserver(this.#updateImg.bind(this));
mutations.observe(this, { childList: true });
onDispose(() => mutations.disconnect());

effect(() => {
const img = this.$state.img();
if (!img) return;

const { loading, hidden } = this.$state;
this.#img.style.display = loading() || hidden() ? 'none' : '';
img.style.display = loading() || hidden() ? 'none' : '';
});

effect(() => {
setAttribute(this.#img, 'alt', alt());
setAttribute(this.#img, 'crossorigin', crossOrigin());
setAttribute(this.#img, 'src', src());
const img = this.$state.img();
if (!img) return;

this.#setImgAttr(img, 'alt', alt());
this.#setImgAttr(img, 'crossorigin', crossOrigin());
this.#setImgAttr(img, 'src', src());
this.#setImgAttr(img, 'loading', loading());
this.#setImgAttr(img, 'decoding', decoding());
this.#setImgAttr(img, 'fetchpriority', fetchPriority());
});
}

#updateImg(): void {
const img = this.#findImg() ?? this.#defaultImg;

if (img === this.#defaultImg) {
if (this.#defaultImg.parentNode !== this) {
this.prepend(this.#defaultImg);
}
} else {
this.#saveCustomImgAttrs(img);
this.#copySrcFromImg(img);
this.#defaultImg.remove();
}

if (this.$state.img() !== img) {
this.$state.img.set(img);
}
}

if (this.#img.parentNode !== this) {
this.prepend(this.#img);
#findImg(): HTMLImageElement | null {
for (const child of this.children) {
if (child.localName === 'img' && child !== this.#defaultImg) {
return child as HTMLImageElement;
}
}

return null;
}

#saveCustomImgAttrs(img: HTMLImageElement): void {
if (this.#customImgAttrs.has(img)) return;

const attrs = new Map<ManagedImgAttr, string>();

for (const attr of managedImgAttrs) {
const value = img.getAttribute(attr);
if (value !== null) attrs.set(attr, value);
}

this.#customImgAttrs.set(img, attrs);
}

#copySrcFromImg(img: HTMLImageElement): void {
if (this.$props.src() === null && img.hasAttribute('src')) {
this.src = img.getAttribute('src') || '';
}
}

#setImgAttr(img: HTMLImageElement, attr: ManagedImgAttr, value: string | null): void {
if (img !== this.#defaultImg && this.#customImgAttrs.get(img)?.has(attr)) return;
setAttribute(img, attr, value);
}
}

Expand Down
Loading