import { MediaStateChangeEvents } from './constants.js'; import MediaController from './media-controller.js'; import { globalThis, document } from './utils/server-safe-globals.js'; import { TemplateInstance } from './utils/template-parts.js'; import { processor } from './utils/template-processor.js'; import { camelCase, isNumericString } from './utils/utils.js'; // Export Template parts for players. export * from './utils/template-parts.js'; const observedMediaAttributes = { mediatargetlivewindow: 'targetlivewindow', mediastreamtype: 'streamtype', }; const prependTemplate = document.createElement('template'); prependTemplate.innerHTML = /*html*/ ` `; /** * @extends {HTMLElement} * * @attr {string} template - The element `id` of the template to render. */ export class MediaThemeElement extends globalThis.HTMLElement { static template: HTMLTemplateElement; static observedAttributes = ['template']; static processor = processor; renderRoot: ShadowRoot; renderer?: TemplateInstance; #template: HTMLTemplateElement; #prevTemplate: HTMLTemplateElement; #prevTemplateId: string | null; #observer: MutationObserver; constructor() { super(); if (this.shadowRoot) { this.renderRoot = this.shadowRoot; } else { // Set up the Shadow DOM if not using Declarative Shadow DOM. this.renderRoot = this.attachShadow({ mode: 'open' }); this.createRenderer(); } this.#observer = new MutationObserver((mutationList) => { // Only update if `` has computed breakpoints at least once. if (this.mediaController && !this.mediaController?.breakpointsComputed) return; if ( mutationList.some((mutation) => { const target = mutation.target as HTMLElement; // Render on each attribute change of the `` element. if (target === this) return true; // Only check ``'s attributes below. if (target.localName !== 'media-controller') return false; // Render if this attribute is directly observed. if (observedMediaAttributes[mutation.attributeName]) return true; // Render if `breakpointx` attributes change. if (mutation.attributeName.startsWith('breakpoint')) return true; return false; }) ) { this.render(); } }); this.#renderBind = this.render.bind(this); // In case the template prop was set before custom element upgrade. // https://web.dev/custom-elements-best-practices/#make-properties-lazy this.#upgradeProperty('template'); } #upgradeProperty(prop: string): void { if (Object.prototype.hasOwnProperty.call(this, prop)) { const value = this[prop]; // Delete the set property from this instance. delete this[prop]; // Set the value again via the (prototype) setter on this class. this[prop] = value; } } /** @type {HTMLElement & { breakpointsComputed?: boolean }} */ get mediaController(): MediaController { // Expose the media controller if API access is needed return this.renderRoot.querySelector('media-controller'); } get template(): string | HTMLTemplateElement | null { return ( this.#template ?? (this.constructor as typeof MediaThemeElement).template ); } set template(value: string | HTMLTemplateElement | null) { if (value === null) { this.removeAttribute('template'); return; } if (typeof value === 'string') { this.setAttribute('template', value); } else if (value instanceof HTMLTemplateElement) { this.#template = value; this.#prevTemplateId = null; this.createRenderer(); } } get props() { const observedAttributes = [ ...Array.from(this.mediaController?.attributes ?? []).filter( ({ name }) => { return observedMediaAttributes[name] || name.startsWith('breakpoint'); } ), ...Array.from(this.attributes), ]; const props = {}; for (const attr of observedAttributes) { const name = observedMediaAttributes[attr.name] ?? camelCase(attr.name); let { value } = attr; if (value != null) { if (isNumericString(value)) { // @ts-ignore value = parseFloat(value); } props[name] = value === '' ? true : value; } else { props[name] = false; } } return props; } attributeChangedCallback( attrName: string, oldValue: string, newValue: string | null ): void { if (attrName === 'template' && oldValue != newValue) { this.#updateTemplate(); } } connectedCallback(): void { this.addEventListener( MediaStateChangeEvents.BREAKPOINTS_COMPUTED, this.#renderBind ); // Observe the `` element for attribute changes. this.#observer.observe(this, { attributes: true }); // Observe the subtree of the render root, by default the elements in the shadow dom. this.#observer.observe(this.renderRoot, { attributes: true, subtree: true, }); this.#updateTemplate(); } disconnectedCallback(): void { this.removeEventListener( MediaStateChangeEvents.BREAKPOINTS_COMPUTED, this.#renderBind ); this.#observer.disconnect(); } #updateTemplate(): void { const templateId = this.getAttribute('template'); if (!templateId || templateId === this.#prevTemplateId) return; const rootNode = this.getRootNode() as Document; const template = rootNode?.getElementById?.( templateId ) as HTMLTemplateElement | null; if (template) { this.#prevTemplateId = templateId; this.#template = template; this.createRenderer(); return; } if (isValidUrl(templateId)) { this.#prevTemplateId = templateId; request(templateId) .then((data) => { const template = document.createElement('template'); template.innerHTML = data; this.#template = template; this.createRenderer(); }) .catch(console.error); } } createRenderer(): void { if ( this.template instanceof HTMLTemplateElement && this.template !== this.#prevTemplate ) { this.#prevTemplate = this.template as HTMLTemplateElement; this.renderer = new TemplateInstance( this.template, this.props, // @ts-ignore this.constructor.processor ); this.renderRoot.textContent = ''; this.renderRoot.append( prependTemplate.content.cloneNode(true), this.renderer ); } } #renderBind: () => void; render(): void { this.renderer?.update(this.props); } } function isValidUrl(url: string): boolean { // Valid URL e.g. /absolute, ./relative, http://, https:// if (!/^(\/|\.\/|https?:\/\/)/.test(url)) return false; // Add base if url doesn't start with http:// or https:// const base = /^https?:\/\//.test(url) ? undefined : location.origin; try { new URL(url, base); } catch (e) { return false; } return true; } async function request(resource: string | URL | Request): Promise { const response = await fetch(resource); if (response.status !== 200) { throw new Error( `Failed to load resource: the server responded with a status of ${response.status}` ); } return response.text(); } if (!globalThis.customElements.get('media-theme')) { globalThis.customElements.define('media-theme', MediaThemeElement); }