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);
}