diff --git a/internal/design/media-api.md b/internal/design/media-api.md index 6b8a95c6..ac14a2bc 100644 --- a/internal/design/media-api.md +++ b/internal/design/media-api.md @@ -161,7 +161,7 @@ interface MediaApi extends MediaBaseApi, export interface MediaApiProxyTarget extends EventTarget {} // Proxy mixin that creates a MediaApi from the passed classes and proxies the methods and properties to the attached target. -export const MediaApiProxyMixin = ( +export const MediaProxyMixin = ( ...MediaApiTargetClasses: AnyConstructor[] ) => class MediaApiProxy { // Logic that proxies media API to the media target @@ -179,9 +179,9 @@ export const MediaApiProxyMixin = ( } // Size optimized for the most common use case of proxying HTMLMediaElement. -export class MediaApiProxy extends MediaApiProxyMixin(HTMLMediaElement, EventTarget) {} -export class VideoApiProxy extends MediaApiProxyMixin(HTMLVideoElement, HTMLMediaElement, EventTarget) {} -export class AudioApiProxy extends MediaApiProxyMixin(HTMLAudioElement, HTMLMediaElement, EventTarget) {} +export class MediaApiProxy extends MediaProxyMixin(HTMLMediaElement, EventTarget) {} +export class VideoApiProxy extends MediaProxyMixin(HTMLVideoElement, HTMLMediaElement, EventTarget) {} +export class AudioApiProxy extends MediaProxyMixin(HTMLAudioElement, HTMLMediaElement, EventTarget) {} ``` ### Example of using the media API Proxy diff --git a/packages/core/src/core/media/delegate.ts b/packages/core/src/core/media/delegate.ts new file mode 100644 index 00000000..cceec492 --- /dev/null +++ b/packages/core/src/core/media/delegate.ts @@ -0,0 +1,62 @@ +import type { Constructor } from '@videojs/utils/types'; + +export interface MediaDelegate { + attach?(target: EventTarget): void; + detach?(): void; +} + +/** + * Mixin that intercepts `get`, `set`, and `call` to delegate property access + * and method calls to an instance of `DelegateClass` before falling through + * to the base class implementation. + * + * Works with both `CustomMediaMixin` and `MediaProxyMixin`. + */ +export function MediaDelegateMixin, Delegate extends Constructor>( + BaseClass: Base, + DelegateClass: Delegate +) { + class DelegateMedia extends (BaseClass as Constructor) { + #delegate = new DelegateClass(); + + get(prop: string): any { + if (prop in this.#delegate) { + return (this.#delegate as any)[prop]; + } + return super.get?.(prop); + } + + set(prop: string, val: any): void { + if (prop in this.#delegate) { + (this.#delegate as any)[prop] = val; + return; + } + super.set?.(prop, val); + } + + call(prop: string, ...args: any[]): any { + if (prop in this.#delegate) { + return (this.#delegate as any)[prop](...args); + } + return super.call?.(prop, ...args); + } + + attach(target: EventTarget): void { + super.attach?.(target); + this.#delegate.attach?.(target); + } + + detach(): void { + this.#delegate.detach?.(); + super.detach?.(); + } + } + + return DelegateMedia as unknown as Constructor< + InstanceType & { + attach(target: EventTarget): void; + detach(): void; + } + > & + Omit; +} diff --git a/packages/core/src/core/media/proxy.ts b/packages/core/src/core/media/proxy.ts index 1829852c..90b5a58d 100644 --- a/packages/core/src/core/media/proxy.ts +++ b/packages/core/src/core/media/proxy.ts @@ -19,12 +19,13 @@ const API_GET_SET: API_TYPE = 2; * * The `get`, `set`, and `call` methods can be overridden to provide catch-all custom behavior. */ -export const MediaApiProxyMixin = ( - ...MediaApiTargetClasses: AnyConstructor[] +export const MediaProxyMixin = ( + PrimaryClass: AnyConstructor, + ...AdditionalClasses: AnyConstructor[] ) => { class MediaApiProxy { - static extends(...MediaApiTargetClasses: AnyConstructor[]) { - const props = getClassProps(...MediaApiTargetClasses); + static extends(...MediaApiTargetClasses: AnyConstructor[]) { + const props = getClassProps(...MediaApiTargetClasses); for (const [prop, type] of props.entries()) { if (prop in MediaApiProxy.prototype) continue; @@ -81,9 +82,9 @@ export const MediaApiProxyMixin = ( } } - MediaApiProxy.extends(...MediaApiTargetClasses); + MediaApiProxy.extends(PrimaryClass, ...AdditionalClasses); - return MediaApiProxy as unknown as Constructor & typeof MediaApiProxy; + return MediaApiProxy as unknown as Constructor; }; /** diff --git a/packages/html/src/media/custom-media-element.ts b/packages/core/src/dom/media/custom-media-element.ts similarity index 87% rename from packages/html/src/media/custom-media-element.ts rename to packages/core/src/dom/media/custom-media-element.ts index 13548708..1b9f7bfb 100644 --- a/packages/html/src/media/custom-media-element.ts +++ b/packages/core/src/dom/media/custom-media-element.ts @@ -1,5 +1,3 @@ -// TODO: This should be adapted to use the MediaApiMixin as a base class. - /** * Custom Media Element * Based on https://github.com/muxinc/custom-video-element - Mux - MIT License @@ -187,6 +185,7 @@ export function CustomMediaMixin>( static shadowRootOptions: ShadowRootInit = { mode: 'open' }; static Events = Events; static #isDefined = false; + static #propsToAttrs: Set; static get observedAttributes() { CustomMedia.#define(); @@ -202,9 +201,9 @@ export function CustomMediaMixin>( if (CustomMedia.#isDefined) return; CustomMedia.#isDefined = true; - const propsToAttrs = new Set(CustomMedia.observedAttributes); + CustomMedia.#propsToAttrs = new Set(CustomMedia.observedAttributes); // defaultMuted maps to the muted attribute, handled manually below. - propsToAttrs.delete('muted'); + CustomMedia.#propsToAttrs.delete('muted'); // Passthrough native element functions from the custom element to the native element for (const prop of nativeElProps) { @@ -215,53 +214,21 @@ export function CustomMediaMixin>( // @ts-expect-error CustomMedia.prototype[prop] = function (...args: any[]) { this.#init(); - - const fn = () => { - if (this.call) return this.call(prop, ...args); - const nativeFn = this.nativeEl?.[prop] as ((...args: any[]) => any) | undefined; - return nativeFn?.apply(this.nativeEl, args); - }; - - return fn(); + return this.call(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.get?.(prop) ?? this.nativeEl?.[prop]; + return this.get(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.set) { - this.set(prop, val); - return; - } - - if (this.nativeEl) { - // @ts-expect-error - this.nativeEl[prop] = val; - } + this.set(prop, val); }; } @@ -276,9 +243,36 @@ export function CustomMediaMixin>( #childMap = new Map(); #childObserver?: MutationObserver; - get: ((prop: string) => any) | undefined; - set: ((prop: string, val: any) => void) | undefined; - call: ((prop: string, ...args: any[]) => any) | undefined; + get(prop: string): any { + const attr = prop.toLowerCase(); + if (CustomMedia.#propsToAttrs.has(attr)) { + const val = this.getAttribute(attr); + return val === null ? false : val === '' ? true : val; + } + return this.nativeEl?.[prop as keyof typeof this.nativeEl]; + } + + set(prop: string, val: any): void { + const attr = prop.toLowerCase(); + if (CustomMedia.#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-expect-error + this.nativeEl[prop as keyof typeof this.nativeEl] = val; + } + } + + call(prop: string, ...args: any[]): any { + const nativeFn = this.nativeEl?.[prop as keyof typeof this.nativeEl] as ((...args: any[]) => any) | undefined; + return nativeFn?.apply(this.nativeEl, args); + } // If the custom element is defined before the custom element's HTML is parsed // no attributes will be available in the constructor (construction process). @@ -301,27 +295,13 @@ export function CustomMediaMixin>( } get defaultMuted() { - return this.hasAttribute('muted'); + this.#init(); + return this.get('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}`); + this.#init(); + this.set('muted', val); } #init(): void { diff --git a/packages/core/src/dom/media/hls.ts b/packages/core/src/dom/media/hls.ts index c6f74ff2..30022349 100644 --- a/packages/core/src/dom/media/hls.ts +++ b/packages/core/src/dom/media/hls.ts @@ -1,34 +1,37 @@ -import type { AnyConstructor } from '@videojs/utils/types'; import Hls from 'hls.js'; -import type { MediaApiProxyTarget } from '../../core/media/proxy'; -import { VideoApiProxy } from './proxy'; +import { type MediaDelegate, MediaDelegateMixin } from '../../core/media/delegate'; +import { MediaProxyMixin } from '../../core/media/proxy'; +import { CustomMediaMixin } from './custom-media-element'; + +export class HlsMediaDelegate implements MediaDelegate { + #engine = new Hls(); + + attach(target: EventTarget): void { + this.#engine.attachMedia(target as HTMLMediaElement); + } + + detach(): void { + this.#engine.detachMedia(); + } + + set src(src: string) { + this.#engine.loadSource(src); + } + + get src(): string { + return this.#engine.url ?? ''; + } +} // This is used by the web component because it needs to extend HTMLElement! -export const HlsMediaMixin = >(Super: T) => { - class HlsMedia extends Super { - engine = new Hls(); - - attach(target: MediaApiProxyTarget): void { - super.attach?.(target); - this.engine.attachMedia(target as HTMLMediaElement); - } - - detach(): void { - super.detach?.(); - this.engine.detachMedia(); - } - - set src(value: string) { - this.engine.loadSource(value); - } - - get src(): string { - return this.engine.url ?? ''; - } - } - return HlsMedia as T & typeof HlsMedia; -}; +export class HlsCustomMedia extends MediaDelegateMixin( + CustomMediaMixin(HTMLElement, { tag: 'video' }), + HlsMediaDelegate +) {} // This is used by the React component. -export class HlsMedia extends HlsMediaMixin(VideoApiProxy) {} +export class HlsMedia extends MediaDelegateMixin( + MediaProxyMixin(HTMLVideoElement, HTMLMediaElement, EventTarget), + HlsMediaDelegate +) {} diff --git a/packages/core/src/dom/media/proxy.ts b/packages/core/src/dom/media/proxy.ts deleted file mode 100644 index 2a97bb1c..00000000 --- a/packages/core/src/dom/media/proxy.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { MediaApiProxyMixin } from '../../core/media/proxy'; - -export type { MediaApiProxyTarget } from '../../core/media/proxy'; - -export class MediaApiProxy extends MediaApiProxyMixin(HTMLMediaElement, EventTarget) {} -export class VideoApiProxy extends MediaApiProxyMixin( - HTMLVideoElement, - HTMLMediaElement, - EventTarget -) {} -export class AudioApiProxy extends MediaApiProxyMixin( - HTMLAudioElement, - HTMLMediaElement, - EventTarget -) {} diff --git a/packages/core/src/dom/tsconfig.json b/packages/core/src/dom/tsconfig.json index 44b4edee..e3330927 100644 --- a/packages/core/src/dom/tsconfig.json +++ b/packages/core/src/dom/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../../tsconfig.base.json", "compilerOptions": { "composite": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "declarationDir": "../../types/dom", "useDefineForClassFields": false }, diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 976b7bb6..9fdf55bf 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -10,6 +10,7 @@ const createConfig = (mode: BuildMode): UserConfig => ({ index: './src/core/index.ts', dom: './src/dom/index.ts', 'dom/media/hls': './src/dom/media/hls.ts', + 'dom/media/custom-media-element': './src/dom/media/custom-media-element.ts', }, platform: 'neutral', format: 'es', diff --git a/packages/html/src/media/background-video/index.ts b/packages/html/src/media/background-video/index.ts index 3f44da5a..c0903b8d 100644 --- a/packages/html/src/media/background-video/index.ts +++ b/packages/html/src/media/background-video/index.ts @@ -1,4 +1,4 @@ -import { CustomMediaMixin } from '../custom-media-element'; +import { CustomMediaMixin } from '@videojs/core/dom/media/custom-media-element'; function getTemplateHTML(attrs: Record) { return /*html*/ ` diff --git a/packages/html/src/media/hls-video/index.ts b/packages/html/src/media/hls-video/index.ts index 4c1feed3..65aec913 100644 --- a/packages/html/src/media/hls-video/index.ts +++ b/packages/html/src/media/hls-video/index.ts @@ -1,7 +1,6 @@ -import { HlsMediaMixin } from '@videojs/core/dom/media/hls'; -import { CustomMediaMixin } from '../custom-media-element'; +import { HlsCustomMedia } from '@videojs/core/dom/media/hls'; -export class HlsVideo extends HlsMediaMixin(CustomMediaMixin(HTMLElement, { tag: 'video' })) { +export class HlsVideo extends HlsCustomMedia { static getTemplateHTML(attrs: Record): string { const { src, ...rest } = attrs; // biome-ignore lint/complexity/noThisInStatic: intentional use of super