From 918ca82922750018d329406e7f31432a37b155ff Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Fri, 22 Nov 2024 14:30:55 -0600 Subject: [PATCH 1/5] feat: use Typescript in custom-media-element --- package-lock.json | 17 + .../custom-media-element.ts | 455 ++++++++++++++++++ .../custom-media-element/examples/example.js | 2 +- packages/custom-media-element/package.json | 46 +- .../test/eager-upgrade.html | 2 +- .../test/lazy-upgrade.html | 2 +- packages/custom-media-element/test/test.js | 2 +- packages/custom-media-element/tsconfig.json | 17 + 8 files changed, 534 insertions(+), 9 deletions(-) create mode 100644 packages/custom-media-element/custom-media-element.ts create mode 100644 packages/custom-media-element/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 1049524..2a340fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6075,10 +6075,27 @@ "version": "1.3.3", "license": "MIT", "devDependencies": { + "@typescript-eslint/eslint-plugin": "^7.12.0", + "@typescript-eslint/parser": "^7.12.0", "esbuild": "^0.21.4", + "typescript": "5.4.5", "wet-run": "^1.2.2" } }, + "packages/custom-media-element/node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "packages/dash-video-element": { "version": "0.0.12", "license": "MIT", diff --git a/packages/custom-media-element/custom-media-element.ts b/packages/custom-media-element/custom-media-element.ts new file mode 100644 index 0000000..51df4ce --- /dev/null +++ b/packages/custom-media-element/custom-media-element.ts @@ -0,0 +1,455 @@ +/** + * 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', +]; + +/** + * Helper function to generate the HTML template for audio elements. + */ +function getAudioTemplateHTML(attrs: Record): string { + return /*html*/ ` + + + + + + `; +} + +/** + * Helper function to generate the HTML template for video elements. + */ +function getVideoTemplateHTML(attrs: Record): string { + return /*html*/ ` + + + + + + `; +} + +type Constructor = { + new (): T +} + +type MediaChild = HTMLTrackElement | HTMLSourceElement; + +declare class CustomAudioElementClass extends HTMLAudioElement implements HTMLAudioElement { + static readonly observedAttributes: string[]; + static getTemplateHTML: typeof getAudioTemplateHTML; + static shadowRootOptions: ShadowRootInit; + static Events: string[]; + readonly nativeEl: HTMLAudioElement; + attributeChangedCallback(attrName: string, oldValue?: string | null, newValue?: string | null): void; + connectedCallback(): void; + disconnectedCallback(): void; + init(): void; + handleEvent(event: Event): void; +} + +declare class CustomVideoElementClass extends HTMLVideoElement implements HTMLVideoElement { + static readonly observedAttributes: string[]; + static getTemplateHTML: typeof getVideoTemplateHTML; + static shadowRootOptions: ShadowRootInit; + static Events: string[]; + readonly nativeEl: HTMLVideoElement; + attributeChangedCallback(attrName: string, oldValue?: string | null, newValue?: string | null): void; + connectedCallback(): void; + disconnectedCallback(): void; + init(): void; + handleEvent(event: Event): void; +} + +type CustomMediaElementConstructor = { + readonly observedAttributes: string[]; + getTemplateHTML: typeof getVideoTemplateHTML | typeof getAudioTemplateHTML; + shadowRootOptions: ShadowRootInit; + Events: string[]; + new(): T; +}; + +type CustomVideoElementConstructor = CustomMediaElementConstructor; +type CustomAudioElementConstructor = CustomMediaElementConstructor; + +/** + * @see https://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/ + */ +export function CustomMediaMixin(superclass: Constructor, { tag, is }: { tag: 'video', is?: string }): Constructor & CustomVideoElementConstructor; +export function CustomMediaMixin(superclass: Constructor, { tag, is }: { tag: 'audio', is?: string }): Constructor & CustomAudioElementConstructor; +export function CustomMediaMixin(superclass: Constructor, { tag, is }: { tag: 'audio' | 'video', is?: string }): Constructor & any { + // `is` makes it possible to extend a custom built-in. e.g., castable-video + const nativeElTest = globalThis.document?.createElement?.(tag, { is } as any); + const nativeElProps = nativeElTest ? getNativeElProps(nativeElTest) : []; + + return class CustomMedia extends superclass { + static getTemplateHTML = tag.endsWith('audio') ? getAudioTemplateHTML : getVideoTemplateHTML; + static shadowRootOptions: ShadowRootInit = { mode: 'open' }; + static Events = Events; + static #isDefined = false; + + static get observedAttributes() { + CustomMedia.#define(); + + // Include any attributes from the custom built-in. + // @ts-ignore + const natAttrs = nativeElTest?.constructor?.observedAttributes ?? []; + + return [ + ...natAttrs, + 'autopictureinpicture', + 'disablepictureinpicture', + 'disableremoteplayback', + 'autoplay', + 'controls', + 'controlslist', + 'crossorigin', + 'loop', + 'muted', + 'playsinline', + 'poster', + 'preload', + 'src', + ]; + } + + static #define(): void { + 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 element functions from the custom element to the native element + for (const prop of nativeElProps) { + if (prop in this.prototype) continue; + + if (typeof nativeElTest[prop] === 'function') { + // Function + // @ts-ignore + this.prototype[prop] = function (...args: any[]) { + this.#init(); + // @ts-ignore + return this.nativeEl?.[prop](...args); + }; + } else { + // Getter and setter configuration + const config: PropertyDescriptor = { + get(this: CustomMedia) { + this.#init(); + const attr = prop.toLowerCase(); + if (propsToAttrs.has(attr)) { + const val = this.getAttribute(attr); + return val === null ? false : val === '' ? true : val; + } + return this.nativeEl?.[prop]; + }, + }; + + if (prop !== prop.toUpperCase()) { + config.set = function (this: CustomMedia, val: any) { + 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.nativeEl) { + // @ts-ignore + this.nativeEl[prop] = val; + } + }; + } + + Object.defineProperty(this.prototype, prop, config); + } + } + } + + // Private fields + #isInit = false; + #nativeEl: HTMLVideoElement | HTMLAudioElement | null = null; + #childMap = new Map(); + #childObserver?: MutationObserver; + + 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.shadowRoot?.querySelector(tag) ?? + this.querySelector(':scope > [slot=media]') ?? + this.querySelector(tag) + ); + } + + set nativeEl(val: HTMLVideoElement | HTMLAudioElement | null) { + 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?.getAttribute('preload'); + } + + set preload(val) { + this.setAttribute('preload', `${val}`); + } + + #init(): void { + if (this.#isInit) return; + this.#isInit = true; + this.init(); + } + + init(): void { + 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 as typeof CustomMedia).getTemplateHTML(attrs); + } + + this.nativeEl!.muted = this.hasAttribute('muted'); + this.#childObserver = new MutationObserver(this.#syncMediaChildAttribute.bind(this)); + this.shadowRoot!.addEventListener('slotchange', this); + this.#syncMediaChildren(); + + for (const type of (this.constructor as typeof CustomMedia).Events) { + this.shadowRoot?.addEventListener(type, this.handleEvent.bind(this), true); + } + } + + handleEvent(event: Event): void { + if (event.type === 'slotchange') { + this.#syncMediaChildren(); + return; + } + + if (event.target === this.nativeEl) { + this.dispatchEvent(new CustomEvent(event.type, { detail: (event as CustomEvent).detail })); + } + } + + #syncMediaChildren(): void { + const removeNativeChildren = new Map(this.#childMap); + const defaultSlot = this.shadowRoot?.querySelector('slot:not([name])') as HTMLSlotElement; + + const mediaChildren = defaultSlot + ?.assignedElements({ flatten: true }) + .filter((el) => ['track', 'source'].includes(el.localName)) as MediaChild[]; + + mediaChildren + .forEach((el) => { + removeNativeChildren.delete(el); + let clone = this.#childMap.get(el); + if (!clone) { + clone = el.cloneNode() as MediaChild; + this.#childMap.set(el, clone); + this.#childObserver?.observe(el, { attributes: true }); + } + this.nativeEl?.append(clone); + this.#enableDefaultTrack(clone as HTMLTrackElement); + }); + + removeNativeChildren.forEach((clone, el) => { + clone.remove(); + this.#childMap.delete(el); + }); + } + + #syncMediaChildAttribute(mutations: MutationRecord[]): void { + for (const mutation of mutations) { + if (mutation.type === 'attributes') { + const { target, attributeName } = mutation; + const clone = this.#childMap.get(target as MediaChild); + if (clone && attributeName) { + clone.setAttribute(attributeName, (target as MediaChild).getAttribute(attributeName) ?? ''); + this.#enableDefaultTrack(clone as HTMLTrackElement); + } + } + } + } + + #enableDefaultTrack(trackEl: HTMLTrackElement): void { + // Enable default text tracks for chapters or metadata + if ( + trackEl && + trackEl.localName === 'track' && + trackEl.default && + (trackEl.kind === 'chapters' || trackEl.kind === 'metadata') && + trackEl.track.mode === 'disabled' + ) { + trackEl.track.mode = 'hidden'; + } + } + + attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string | null): void { + this.#init(); + this.#forwardAttribute(attrName, oldValue, newValue); + } + + #forwardAttribute(attrName: string, oldValue: string | null, newValue: string | null): void { + if (['id', 'class'].includes(attrName)) return; + + if ( + !CustomMedia.observedAttributes.includes(attrName) && + (this.constructor as typeof CustomMedia).observedAttributes.includes(attrName) + ) { + return; + } + + if (newValue === null) { + this.nativeEl?.removeAttribute(attrName); + } else if (this.nativeEl?.getAttribute(attrName) !== newValue) { + this.nativeEl?.setAttribute(attrName, newValue); + } + } + + connectedCallback(): void { + this.#init(); + } + }; +} + +/** + * Helper function to get all properties from a native media element's prototype. + */ +function getNativeElProps(nativeElTest: HTMLVideoElement | HTMLAudioElement) { + const nativeElProps: (keyof typeof nativeElTest)[] = []; + for ( + let proto = Object.getPrototypeOf(nativeElTest); + proto && proto !== HTMLElement.prototype; + proto = Object.getPrototypeOf(proto) + ) { + const props = Object.getOwnPropertyNames(proto) as (keyof typeof nativeElTest)[]; + nativeElProps.push(...props); + } + return nativeElProps; +} + +/** + * Helper function to serialize attributes into a string. + */ +function serializeAttributes(attrs: Record): string { + return Object.entries(attrs) + .map(([key, value]) => (value === '' ? ` ${key}` : ` ${key}="${value}"`)) + .join(''); +} + +/** + * Helper function to convert NamedNodeMap to a plain object. + */ +function namedNodeMapToObject(namedNodeMap: NamedNodeMap): Record { + const obj: Record = {}; + for (const attr of Array.from(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', +}); diff --git a/packages/custom-media-element/examples/example.js b/packages/custom-media-element/examples/example.js index e2d4bdd..db36738 100644 --- a/packages/custom-media-element/examples/example.js +++ b/packages/custom-media-element/examples/example.js @@ -1,4 +1,4 @@ -import { CustomVideoElement, CustomAudioElement } from '../custom-media-element.js'; +import { CustomVideoElement, CustomAudioElement } from '../dist/custom-media-element.js'; export class MyVideoElement extends CustomVideoElement {} diff --git a/packages/custom-media-element/package.json b/packages/custom-media-element/package.json index 045219c..70df18d 100644 --- a/packages/custom-media-element/package.json +++ b/packages/custom-media-element/package.json @@ -14,21 +14,57 @@ "directory": "packages/custom-media-element" }, "files": [ - "custom-media-element.d.ts" + "dist" ], "type": "module", - "main": "custom-media-element.js", - "types": "custom-media-element.d.ts", + "main": "dist/custom-media-element.js", + "types": "dist/custom-media-element.d.ts", "scripts": { - "lint": "eslint *.js", - "pretest": "esbuild custom-media-element.js --target=es2019 --bundle --outdir=dist --global-name=CustomMediaElement", + "lint": "eslint *.ts", + "pretest": "npm run build", "test": "wet test test/eager-upgrade.html test/lazy-upgrade.html --coverage", + "dev": "tsc -w & esbuild custom-media-element.ts --format=esm --outdir=dist --watch=forever", + "build:esm": "esbuild custom-media-element.ts --format=esm --outdir=dist", + "build:iife": "esbuild custom-media-element.ts --bundle --outfile=dist/custom-media-element.iife.js --global-name=CustomMediaElement", + "build": "rm -rf dist && npm run build:esm && npm run build:iife", + "postbuild": "tsc", "serve": "wet serve --redirect :examples/ --cors" }, "devDependencies": { + "@typescript-eslint/eslint-plugin": "^7.12.0", + "@typescript-eslint/parser": "^7.12.0", "esbuild": "^0.21.4", + "typescript": "5.4.5", "wet-run": "^1.2.2" }, + "eslintConfig": { + "root": true, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "parser": "@typescript-eslint/parser", + "plugins": [ + "@typescript-eslint" + ], + "globals": { + "globalThis": "writable" + }, + "env": { + "browser": true, + "es6": true, + "node": true, + "mocha": true + }, + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "rules": { + "@typescript-eslint/ban-ts-comment": 0, + "@typescript-eslint/no-explicit-any": 0 + } + }, "keywords": [ "custom", "element", diff --git a/packages/custom-media-element/test/eager-upgrade.html b/packages/custom-media-element/test/eager-upgrade.html index d5ae42f..27fde5d 100644 --- a/packages/custom-media-element/test/eager-upgrade.html +++ b/packages/custom-media-element/test/eager-upgrade.html @@ -8,7 +8,7 @@ } } - + - + diff --git a/packages/custom-media-element/test/test.js b/packages/custom-media-element/test/test.js index b3103f5..8f1045c 100644 --- a/packages/custom-media-element/test/test.js +++ b/packages/custom-media-element/test/test.js @@ -1,5 +1,5 @@ import { test } from 'zora'; -import { CustomVideoElement } from '../custom-media-element.js'; +import { CustomVideoElement } from '../dist/custom-media-element.js'; // The custom-video-element JS import is defined in web-test-runner.config.js // for both an eager and lazy custom element upgrade. diff --git a/packages/custom-media-element/tsconfig.json b/packages/custom-media-element/tsconfig.json new file mode 100644 index 0000000..d8fad96 --- /dev/null +++ b/packages/custom-media-element/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Node", + "declaration": true, + "emitDeclarationOnly": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "preserveWatchOutput": true, + "rootDir": ".", + "outDir": "dist", + }, + "include": ["custom-media-element.ts"], + "exclude": ["node_modules"] +} From 958139a542bbed35dd5fca3b88f8d4946c42865a Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Fri, 22 Nov 2024 15:37:24 -0600 Subject: [PATCH 2/5] fix: TS issue in mux-video --- .../custom-media-element.ts | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/custom-media-element/custom-media-element.ts b/packages/custom-media-element/custom-media-element.ts index 51df4ce..3379171 100644 --- a/packages/custom-media-element/custom-media-element.ts +++ b/packages/custom-media-element/custom-media-element.ts @@ -99,7 +99,7 @@ function getVideoTemplateHTML(attrs: Record): string { } type Constructor = { - new (): T + new (...args: any[]): T } type MediaChild = HTMLTrackElement | HTMLSourceElement; @@ -144,9 +144,9 @@ type CustomAudioElementConstructor = CustomMediaElementConstructor, { tag, is }: { tag: 'video', is?: string }): Constructor & CustomVideoElementConstructor; -export function CustomMediaMixin(superclass: Constructor, { tag, is }: { tag: 'audio', is?: string }): Constructor & CustomAudioElementConstructor; -export function CustomMediaMixin(superclass: Constructor, { tag, is }: { tag: 'audio' | 'video', is?: string }): Constructor & any { +export function CustomMediaMixin>(superclass: T, { tag, is }: { tag: 'video', is?: string }): CustomVideoElementConstructor; +export function CustomMediaMixin>(superclass: T, { tag, is }: { tag: 'audio', is?: string }): CustomAudioElementConstructor; +export function CustomMediaMixin>(superclass: T, { tag, is }: { tag: 'audio' | 'video', is?: string }): any { // `is` makes it possible to extend a custom built-in. e.g., castable-video const nativeElTest = globalThis.document?.createElement?.(tag, { is } as any); const nativeElProps = nativeElTest ? getNativeElProps(nativeElTest) : []; @@ -246,14 +246,10 @@ export function CustomMediaMixin(superclass: Constructor, { tag, is #childMap = new Map(); #childObserver?: MutationObserver; - 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. - } + // 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(); From 9fe3ecb13dd11077c33a02431f5c66f4281172d1 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Fri, 22 Nov 2024 15:38:28 -0600 Subject: [PATCH 3/5] fix: remove old js and type defs --- .../custom-media-element.d.ts | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 packages/custom-media-element/custom-media-element.d.ts diff --git a/packages/custom-media-element/custom-media-element.d.ts b/packages/custom-media-element/custom-media-element.d.ts deleted file mode 100644 index 3ba79ca..0000000 --- a/packages/custom-media-element/custom-media-element.d.ts +++ /dev/null @@ -1,45 +0,0 @@ - -export const Events: string[]; - -export const audioTemplate: HTMLTemplateElement; -export const videoTemplate: HTMLTemplateElement; - -export class CustomAudioElement extends HTMLAudioElement implements HTMLAudioElement { - static readonly observedAttributes: string[]; - static Events: string[]; - static template: HTMLTemplateElement; - readonly nativeEl: HTMLAudioElement; - attributeChangedCallback(attrName: string, oldValue?: string | null, newValue?: string | null): void; - connectedCallback(): void; - disconnectedCallback(): void; - init(): void; - handleEvent(event: Event): void; -} - -export class CustomVideoElement extends HTMLVideoElement implements HTMLVideoElement { - static readonly observedAttributes: string[]; - static Events: string[]; - static template: HTMLTemplateElement; - readonly nativeEl: HTMLVideoElement; - attributeChangedCallback(attrName: string, oldValue?: string | null, newValue?: string | null): void; - connectedCallback(): void; - disconnectedCallback(): void; - init(): void; - handleEvent(event: Event): void; -} - -export type CustomMediaElementConstructor = { - readonly observedAttributes: string[]; - Events: string[]; - template: HTMLTemplateElement; - new(): T; -}; - -export type CustomVideoElementConstructor = CustomMediaElementConstructor; -export type CustomAudioElementConstructor = CustomMediaElementConstructor; - -export function CustomMediaMixin(superclass: T, options: { tag: 'video', is?: string }): - T & CustomVideoElementConstructor; - -export function CustomMediaMixin(superclass: T, options: { tag: 'audio', is?: string }): - T & CustomAudioElementConstructor; From 55743c048366bc27dcb28b424b6f0a528948f8f3 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Fri, 22 Nov 2024 15:53:26 -0600 Subject: [PATCH 4/5] fix: use same names for class / type --- packages/custom-media-element/custom-media-element.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/custom-media-element/custom-media-element.ts b/packages/custom-media-element/custom-media-element.ts index 3379171..1f6f450 100644 --- a/packages/custom-media-element/custom-media-element.ts +++ b/packages/custom-media-element/custom-media-element.ts @@ -138,14 +138,14 @@ type CustomMediaElementConstructor = { new(): T; }; -type CustomVideoElementConstructor = CustomMediaElementConstructor; -type CustomAudioElementConstructor = CustomMediaElementConstructor; +type CustomVideoElement = CustomMediaElementConstructor; +type CustomAudioElement = CustomMediaElementConstructor; /** * @see https://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/ */ -export function CustomMediaMixin>(superclass: T, { tag, is }: { tag: 'video', is?: string }): CustomVideoElementConstructor; -export function CustomMediaMixin>(superclass: T, { tag, is }: { tag: 'audio', is?: string }): CustomAudioElementConstructor; +export function CustomMediaMixin>(superclass: T, { tag, is }: { tag: 'video', is?: string }): CustomVideoElement; +export function CustomMediaMixin>(superclass: T, { tag, is }: { tag: 'audio', is?: string }): CustomAudioElement; export function CustomMediaMixin>(superclass: T, { tag, is }: { tag: 'audio' | 'video', is?: string }): any { // `is` makes it possible to extend a custom built-in. e.g., castable-video const nativeElTest = globalThis.document?.createElement?.(tag, { is } as any); From 447e7780f13e1d7c3f7cfda46103dabeab467f45 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Mon, 25 Nov 2024 11:35:26 -0600 Subject: [PATCH 5/5] fix: merge conflict w/ main --- .../custom-media-element.js | 462 ------------------ .../custom-media-element.ts | 5 +- 2 files changed, 3 insertions(+), 464 deletions(-) delete mode 100644 packages/custom-media-element/custom-media-element.js diff --git a/packages/custom-media-element/custom-media-element.js b/packages/custom-media-element/custom-media-element.js deleted file mode 100644 index f5d59a9..0000000 --- a/packages/custom-media-element/custom-media-element.js +++ /dev/null @@ -1,462 +0,0 @@ -/** - * 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*/ ` - - - - - - `; -} - -// 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*/ ` - - - - - - `; -} - -/** - * @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 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', -}); diff --git a/packages/custom-media-element/custom-media-element.ts b/packages/custom-media-element/custom-media-element.ts index 1f6f450..1f30641 100644 --- a/packages/custom-media-element/custom-media-element.ts +++ b/packages/custom-media-element/custom-media-element.ts @@ -255,9 +255,10 @@ export function CustomMediaMixin>(superclass: this.#init(); return ( this.#nativeEl ?? - this.shadowRoot?.querySelector(tag) ?? this.querySelector(':scope > [slot=media]') ?? - this.querySelector(tag) + this.querySelector(tag) ?? + this.shadowRoot?.querySelector(tag) ?? + null ); }