custom-media-element
Version:
A custom element for extending the native media elements (<audio> or <video>)
346 lines (342 loc) • 10.1 kB
JavaScript
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
};