diff --git a/packages/react/src/components/ui/poster.tsx b/packages/react/src/components/ui/poster.tsx index 41a6d922b..336073238 100644 --- a/packages/react/src/components/ui/poster.tsx +++ b/packages/react/src/components/ui/poster.tsx @@ -79,9 +79,13 @@ interface PosterImgProps { const PosterImg = React.forwardRef( ({ 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 ( ( 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 }} > diff --git a/packages/vidstack/src/components/ui/poster.ts b/packages/vidstack/src/components/ui/poster.ts index 5f5ea62cb..1fa8ce3b1 100644 --- a/packages/vidstack/src/components/ui/poster.ts +++ b/packages/vidstack/src/components/ui/poster.ts @@ -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. @@ -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 { @@ -49,6 +71,9 @@ export class Poster extends Component { src: null, alt: null, crossOrigin: null, + loading: null, + decoding: null, + fetchPriority: null, }; static state = new State({ diff --git a/packages/vidstack/src/elements/define/poster-element.test.ts b/packages/vidstack/src/elements/define/poster-element.test.ts new file mode 100644 index 000000000..0bed6f15b --- /dev/null +++ b/packages/vidstack/src/elements/define/poster-element.test.ts @@ -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((resolve) => setTimeout(resolve)); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); +} diff --git a/packages/vidstack/src/elements/define/poster-element.ts b/packages/vidstack/src/elements/define/poster-element.ts index bef4891d3..7cd6d7db2 100644 --- a/packages/vidstack/src/elements/define/poster-element.ts +++ b/packages/vidstack/src/elements/define/poster-element.ts @@ -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 @@ -18,31 +29,97 @@ export class MediaPosterElement extends Host(HTMLElement, Poster) { static override attrs: Attributes = { crossOrigin: 'crossorigin', + fetchPriority: 'fetchpriority', }; - #img = document.createElement('img'); + #defaultImg = document.createElement('img'); + #customImgAttrs = new WeakMap>(); 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(); + + 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); } }