UNPKG

custom-media-element

Version:

A custom element for extending the native media elements (<audio> or <video>)

346 lines (342 loc) 10.1 kB
const Events = [ "abort", "canplay", "canplaythrough", "durationchange", "emptied", "encrypted", "ended", "error", "loadeddata", "loadedmetadata", "loadstart", "pause", "play", "playing", "progress", "ratechange", "seeked", "seeking", "stalled", "suspend", "timeupdate", "volumechange", "waiting", "waitingforkey", "resize", "enterpictureinpicture", "leavepictureinpicture", "webkitbeginfullscreen", "webkitendfullscreen", "webkitpresentationmodechanged" ]; const Attributes = [ "autopictureinpicture", "disablepictureinpicture", "disableremoteplayback", "autoplay", "controls", "controlslist", "crossorigin", "loop", "muted", "playsinline", "poster", "preload", "src" ]; function getAudioTemplateHTML(attrs) { return ( /*html*/ ` <style> :host { display: inline-flex; line-height: 0; flex-direction: column; justify-content: end; } audio { width: 100%; } </style> <slot name="media"> <audio${serializeAttributes(attrs)}></audio> </slot> <slot></slot> ` ); } function getVideoTemplateHTML(attrs) { return ( /*html*/ ` <style> :host { display: inline-block; line-height: 0; } video { max-width: 100%; max-height: 100%; min-width: 100%; min-height: 100%; object-fit: var(--media-object-fit, contain); object-position: var(--media-object-position, 50% 50%); } video::-webkit-media-text-track-container { transform: var(--media-webkit-text-track-transform); transition: var(--media-webkit-text-track-transition); } </style> <slot name="media"> <video${serializeAttributes(attrs)}></video> </slot> <slot></slot> ` ); } function CustomMediaMixin(superclass, { tag, is }) { const nativeElTest = globalThis.document?.createElement?.(tag, { is }); const nativeElProps = nativeElTest ? getNativeElProps(nativeElTest) : []; return class CustomMedia extends superclass { static getTemplateHTML = tag.endsWith("audio") ? getAudioTemplateHTML : getVideoTemplateHTML; static shadowRootOptions = { mode: "open" }; static Events = Events; static #isDefined = false; static get observedAttributes() { CustomMedia.#define(); const natAttrs = nativeElTest?.constructor?.observedAttributes ?? []; return [ ...natAttrs, ...Attributes ]; } static #define() { if (this.#isDefined) return; this.#isDefined = true; const propsToAttrs = new Set(this.observedAttributes); propsToAttrs.delete("muted"); for (const prop of nativeElProps) { if (prop in this.prototype) continue; if (typeof nativeElTest[prop] === "function") { this.prototype[prop] = function(...args) { this.#init(); const fn = () => { if (this.call) return this.call(prop, ...args); const nativeFn = this.nativeEl?.[prop]; return nativeFn?.apply(this.nativeEl, args); }; return fn(); }; } else { const config = { get() { this.#init(); const attr = prop.toLowerCase(); if (propsToAttrs.has(attr)) { const val = this.getAttribute(attr); return val === null ? false : val === "" ? true : val; } return this.get?.(prop) ?? this.nativeEl?.[prop]; } }; if (prop !== prop.toUpperCase()) { config.set = function(val) { this.#init(); const attr = prop.toLowerCase(); if (propsToAttrs.has(attr)) { if (val === true || val === false || val == null) { this.toggleAttribute(attr, Boolean(val)); } else { this.setAttribute(attr, val); } return; } if (this.set) { this.set(prop, val); return; } if (this.nativeEl) { this.nativeEl[prop] = val; } }; } Object.defineProperty(this.prototype, prop, config); } } } // Private fields #isInit = false; #nativeEl = null; #childMap = /* @__PURE__ */ new Map(); #childObserver; get; set; call; // If the custom element is defined before the custom element's HTML is parsed // no attributes will be available in the constructor (construction process). // Wait until initializing in the attributeChangedCallback or // connectedCallback or accessing any properties. get nativeEl() { this.#init(); return this.#nativeEl ?? this.querySelector(":scope > [slot=media]") ?? this.querySelector(tag) ?? this.shadowRoot?.querySelector(tag) ?? null; } set nativeEl(val) { this.#nativeEl = val; } get defaultMuted() { return this.hasAttribute("muted"); } set defaultMuted(val) { this.toggleAttribute("muted", val); } get src() { return this.getAttribute("src"); } set src(val) { this.setAttribute("src", `${val}`); } get preload() { return this.getAttribute("preload") ?? this.nativeEl?.preload; } set preload(val) { this.setAttribute("preload", `${val}`); } #init() { if (this.#isInit) return; this.#isInit = true; this.init(); } init() { if (!this.shadowRoot) { this.attachShadow({ mode: "open" }); const attrs = namedNodeMapToObject(this.attributes); if (is) attrs.is = is; if (tag) attrs.part = tag; this.shadowRoot.innerHTML = this.constructor.getTemplateHTML(attrs); } this.nativeEl.muted = this.hasAttribute("muted"); for (const prop of nativeElProps) { this.#upgradeProperty(prop); } this.#childObserver = new MutationObserver(this.#syncMediaChildAttribute.bind(this)); this.shadowRoot.addEventListener("slotchange", this); this.#syncMediaChildren(); for (const type of this.constructor.Events) { this.shadowRoot?.addEventListener(type, this, true); } } handleEvent(event) { if (event.type === "slotchange") { this.#syncMediaChildren(); return; } if (event.target === this.nativeEl) { this.dispatchEvent(new CustomEvent(event.type, { detail: event.detail })); } } #syncMediaChildren() { const removeNativeChildren = new Map(this.#childMap); const defaultSlot = this.shadowRoot?.querySelector("slot:not([name])"); const mediaChildren = defaultSlot?.assignedElements({ flatten: true }).filter((el) => ["track", "source"].includes(el.localName)); mediaChildren.forEach((el) => { removeNativeChildren.delete(el); let clone = this.#childMap.get(el); if (!clone) { clone = el.cloneNode(); this.#childMap.set(el, clone); this.#childObserver?.observe(el, { attributes: true }); } this.nativeEl?.append(clone); this.#enableDefaultTrack(clone); }); removeNativeChildren.forEach((clone, el) => { clone.remove(); this.#childMap.delete(el); }); } #syncMediaChildAttribute(mutations) { for (const mutation of mutations) { if (mutation.type === "attributes") { const { target, attributeName } = mutation; const clone = this.#childMap.get(target); if (clone && attributeName) { clone.setAttribute(attributeName, target.getAttribute(attributeName) ?? ""); this.#enableDefaultTrack(clone); } } } } #enableDefaultTrack(trackEl) { if (trackEl && trackEl.localName === "track" && trackEl.default && (trackEl.kind === "chapters" || trackEl.kind === "metadata") && trackEl.track.mode === "disabled") { trackEl.track.mode = "hidden"; } } #upgradeProperty(prop) { if (Object.prototype.hasOwnProperty.call(this, prop)) { const value = this[prop]; delete this[prop]; this[prop] = value; } } attributeChangedCallback(attrName, oldValue, newValue) { this.#init(); this.#forwardAttribute(attrName, oldValue, newValue); } #forwardAttribute(attrName, _oldValue, newValue) { if (["id", "class"].includes(attrName)) return; if (!CustomMedia.observedAttributes.includes(attrName) && this.constructor.observedAttributes.includes(attrName)) { return; } if (newValue === null) { this.nativeEl?.removeAttribute(attrName); } else if (this.nativeEl?.getAttribute(attrName) !== newValue) { this.nativeEl?.setAttribute(attrName, newValue); } } connectedCallback() { this.#init(); } }; } function getNativeElProps(nativeElTest) { const nativeElProps = []; for (let proto = Object.getPrototypeOf(nativeElTest); proto && proto !== HTMLElement.prototype; proto = Object.getPrototypeOf(proto)) { const props = Object.getOwnPropertyNames(proto); nativeElProps.push(...props); } return nativeElProps; } function serializeAttributes(attrs) { let html = ""; for (const key in attrs) { if (!Attributes.includes(key)) continue; const value = attrs[key]; if (value === "") html += ` ${key}`; else html += ` ${key}="${value}"`; } return html; } function namedNodeMapToObject(namedNodeMap) { const obj = {}; for (const attr of namedNodeMap) { obj[attr.name] = attr.value; } return obj; } const CustomVideoElement = CustomMediaMixin(globalThis.HTMLElement ?? class { }, { tag: "video" }); const CustomAudioElement = CustomMediaMixin(globalThis.HTMLElement ?? class { }, { tag: "audio" }); export { Attributes, CustomAudioElement, CustomMediaMixin, CustomVideoElement, Events };