Files
pole-book/server/node_modules/custom-media-element/custom-media-element.js

463 lines
13 KiB
JavaScript

/**
* Custom Media Element
* Based on https://github.com/muxinc/custom-video-element - Mux - MIT License
*
* The goal is to create an element that works just like the video element
* but can be extended/sub-classed, because native elements cannot be
* extended today across browsers.
*/
// The onevent like props are weirdly set on the HTMLElement prototype with other
// generic events making it impossible to pick these specific to HTMLMediaElement.
export 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',
];
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>
`;
}
// If the `media` slot is used leave the styling up to the user.
// It's a more consistent behavior pre and post custom element upgrade.
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>
`;
}
/**
* @see https://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/
*/
export const CustomMediaMixin = (superclass, { tag, is }) => {
// `is` makes it possible to extend a custom built-in. e.g. castable-video
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;
static get observedAttributes() {
CustomMedia.#define();
// Include any attributes from the custom built-in.
const natAttrs = nativeElTest?.constructor?.observedAttributes ?? [];
return [
...natAttrs,
'autopictureinpicture',
'disablepictureinpicture',
'disableremoteplayback',
'autoplay',
'controls',
'controlslist',
'crossorigin',
'loop',
'muted',
'playsinline',
'poster',
'preload',
'src',
];
}
static #define() {
if (this.#isDefined) return;
this.#isDefined = true;
const propsToAttrs = new Set(this.observedAttributes);
// defaultMuted maps to the muted attribute, handled manually below.
propsToAttrs.delete('muted');
// Passthrough native el functions from the custom el to the native el
for (let prop of nativeElProps) {
if (prop in this.prototype) continue;
const type = typeof nativeElTest[prop];
if (type == 'function') {
// Function
this.prototype[prop] = function (...args) {
this.#init();
const fn = () => {
if (this.call) return this.call(prop, ...args);
return this.nativeEl[prop].apply(this.nativeEl, args);
};
return fn();
};
} else {
// Some properties like src, preload, defaultMuted are handled manually.
// Getter
let config = {
get() {
this.#init();
let 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()) {
// Setter (not a CONSTANT)
config.set = function (val) {
this.#init();
let 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;
}
this.nativeEl[prop] = val;
};
}
Object.defineProperty(this.prototype, prop, config);
}
}
}
#isInit;
#nativeEl;
#childMap = new Map();
#childObserver;
constructor() {
super();
// 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)
);
}
set nativeEl(val) {
this.#nativeEl = val;
}
get defaultMuted() {
return this.hasAttribute('muted');
}
set defaultMuted(val) {
this.toggleAttribute('muted', Boolean(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);
}
// Neither Chrome or Firefox support setting the muted attribute
// after using document.createElement.
// Get around this by setting the muted property manually.
this.nativeEl.muted = this.hasAttribute('muted');
for (let prop of nativeElProps) {
this.#upgradeProperty(prop);
}
this.#childObserver = new MutationObserver(this.#syncMediaChildAttribute);
this.shadowRoot.addEventListener('slotchange', this);
this.#syncMediaChildren();
for (let 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) {
// The video events are dispatched on the CustomMediaElement instance.
// This makes it possible to add event listeners before the element is upgraded.
this.dispatchEvent(new CustomEvent(event.type, { detail: event.detail }));
}
}
/**
* Keep some native child elements like track and source in sync.
* An unnamed <slot> will be filled with all of the custom element's
* top-level child nodes that do not have the slot attribute.
*/
#syncMediaChildren() {
const removeNativeChildren = new Map(this.#childMap);
this.shadowRoot
.querySelector('slot:not([name])')
.assignedElements({ flatten: true })
.filter((el) => ['track', 'source'].includes(el.localName))
.forEach((el) => {
// If the source or track is still in the assigned elements keep it.
removeNativeChildren.delete(el);
// Re-use clones if possible.
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 (let mutation of mutations) {
if (mutation.type === 'attributes') {
const { target, attributeName } = mutation;
const clone = this.#childMap.get(target);
clone?.setAttribute(attributeName, target.getAttribute(attributeName));
this.#enableDefaultTrack(clone);
}
}
};
#enableDefaultTrack(trackEl) {
// https://html.spec.whatwg.org/multipage/media.html#sourcing-out-of-band-text-tracks
// If there are any text tracks in the media element's list of text
// tracks whose text track kind is chapters or metadata that
// correspond to track elements with a default attribute set whose
// text track mode is set to disabled, then set the text track
// mode of all such tracks to hidden.
if (
trackEl.localName === 'track' &&
trackEl.default &&
(trackEl.kind === 'chapters' || trackEl.kind === 'metadata') &&
trackEl.track.mode === 'disabled'
) {
trackEl.track.mode = 'hidden';
}
}
#upgradeProperty(prop) {
// Sets properties that are set before the custom element is upgraded.
// https://web.dev/custom-elements-best-practices/#make-properties-lazy
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;
}
}
attributeChangedCallback(attrName, oldValue, newValue) {
// Initialize right after construction when the attributes become available.
this.#init();
this.#forwardAttribute(attrName, oldValue, newValue);
}
#forwardAttribute(attrName, oldValue, newValue) {
// Ignore a few that don't need to be passed.
if (['id', 'class'].includes(attrName)) {
return;
}
// Ignore setting custom attributes from the child class.
// They should not have any effect on the native element, it adds noise in the DOM.
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) {
// Map all native element properties to the custom element
// so that they're applied to the native element.
// Skipping HTMLElement because of things like "attachShadow"
// causing issues. Most of those props still need to apply to
// the custom element.
let nativeElProps = [];
// Walk the prototype chain up to HTMLElement.
// This will grab all super class props in between.
// i.e. VideoElement and MediaElement
for (
let proto = Object.getPrototypeOf(nativeElTest);
proto && proto !== HTMLElement.prototype;
proto = Object.getPrototypeOf(proto)
) {
nativeElProps.push(...Object.getOwnPropertyNames(proto));
}
return nativeElProps;
}
function serializeAttributes(attrs) {
let html = '';
for (const key in attrs) {
const value = attrs[key];
if (value === '') html += ` ${key}`;
else html += ` ${key}="${value}"`;
}
return html;
}
function namedNodeMapToObject(namedNodeMap) {
let obj = {};
for (let attr of namedNodeMap) {
obj[attr.name] = attr.value;
}
return obj;
}
export const CustomVideoElement = CustomMediaMixin(globalThis.HTMLElement ?? class {}, {
tag: 'video',
});
export const CustomAudioElement = CustomMediaMixin(globalThis.HTMLElement ?? class {}, {
tag: 'audio',
});