feat: add media API + HLS video components (#507)

This commit is contained in:
Wesley Luyten
2026-02-18 13:34:49 -06:00
committed by GitHub
parent e936ea4e44
commit b3a31a335a
19 changed files with 1039 additions and 6 deletions
+1
View File
@@ -1,3 +1,4 @@
export * from './media/proxy';
export * from './media/state';
export * from './ui/buffering-indicator/buffering-indicator-core';
export * from './ui/buffering-indicator/buffering-indicator-data-attrs';
+110
View File
@@ -0,0 +1,110 @@
import type { AnyConstructor, Constructor } from '@videojs/utils/types';
export interface MediaApiProxyTarget extends EventTarget {}
type API_TYPE = 0 | 1 | 2;
const API_METHOD: API_TYPE = 0;
const API_GET: API_TYPE = 1;
const API_GET_SET: API_TYPE = 2;
/**
* This mixin creates an API from the passed classes and proxies the methods and properties to the attached target.
*
* Many methods and properties will need no translation and are proxied directly to the attached target.
* For example, the `play` and `pause` methods are proxied directly to the attached target.
*
* Child classes can override the proxied methods and properties to provide custom behavior.
* For example, the `src` property for HLS media is proxied to the HLS engine, not the target itself.
*
* The `get`, `set`, and `call` methods can be overridden to provide catch-all custom behavior.
*/
export const MediaApiProxyMixin = <T extends EventTarget>(
...MediaApiTargetClasses: AnyConstructor<T extends [unknown, ...unknown[]] ? T : any>[]
) => {
class MediaApiProxy {
static extends(...MediaApiTargetClasses: AnyConstructor<T>[]) {
const props = getClassProps<T>(...MediaApiTargetClasses);
for (const [prop, type] of props.entries()) {
if (prop in MediaApiProxy.prototype) continue;
const config: PropertyDescriptor = {};
if (type === API_METHOD) {
config.value = function (this: MediaApiProxy, ...args: any[]) {
return this.call(prop as keyof MediaApiProxyTarget, ...args);
};
} else if (type === API_GET || type === API_GET_SET) {
config.get = function (this: MediaApiProxy) {
return this.get(prop as keyof MediaApiProxyTarget);
};
if (type === API_GET_SET) {
config.set = function (this: MediaApiProxy, val: any) {
this.set(prop as keyof MediaApiProxyTarget, val);
};
}
}
Object.defineProperty(MediaApiProxy.prototype, prop, config);
}
}
#target: MediaApiProxyTarget | null = null;
get target() {
return this.#target;
}
get(prop: keyof MediaApiProxyTarget): any {
return this.target?.[prop];
}
set(prop: keyof MediaApiProxyTarget, val: any): void {
if (this.target) {
this.target[prop] = val;
}
}
call(prop: keyof MediaApiProxyTarget, ...args: any[]): any {
const nativeFn = this.target?.[prop] as ((...args: any[]) => any) | undefined;
return nativeFn?.apply(this.target, args);
}
attach(target: MediaApiProxyTarget): void {
if (!target || this.#target === target) return;
this.#target = target;
}
detach(): void {
if (!this.#target) return;
this.#target = null;
}
}
MediaApiProxy.extends(...MediaApiTargetClasses);
return MediaApiProxy as unknown as Constructor<T> & typeof MediaApiProxy;
};
/**
* Helper function to get the methods, getters, and setters from a class prototype.
*/
export function getClassProps<T extends EventTarget>(...Classes: AnyConstructor<T>[]) {
const props = new Map<keyof T, API_TYPE>();
for (const Class of Classes) {
const names = Object.getOwnPropertyNames(Class.prototype) as (keyof T)[];
for (const name of names) {
const descriptor = Object.getOwnPropertyDescriptor(Class.prototype, name);
if (typeof descriptor?.value === 'function') {
props.set(name, API_METHOD);
} else if (typeof descriptor?.get === 'function') {
if (typeof descriptor?.set === 'function') {
props.set(name, API_GET_SET);
} else {
props.set(name, API_GET);
}
}
}
}
return props;
}
+1
View File
@@ -1,4 +1,5 @@
export * from './feature';
export * from './media/proxy';
export * from './media/types';
export * from './store/features';
export * from './store/selectors';
+33
View File
@@ -0,0 +1,33 @@
import type { MediaApiProxyTarget } from '@videojs/core';
import type { AnyConstructor } from '@videojs/utils/types';
import Hls from 'hls.js';
import { VideoApiProxy } from './proxy';
// This is used by the web component because it needs to extend HTMLElement!
export const HlsMediaMixin = <T extends AnyConstructor<EventTarget>>(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;
};
// This is used by the React component.
export class HlsMedia extends HlsMediaMixin(VideoApiProxy) {}
+15
View File
@@ -0,0 +1,15 @@
import { MediaApiProxyMixin } from '../../core/media/proxy';
export type { MediaApiProxyTarget } from '../../core/media/proxy';
export class MediaApiProxy extends MediaApiProxyMixin<HTMLMediaElement>(HTMLMediaElement, EventTarget) {}
export class VideoApiProxy extends MediaApiProxyMixin<HTMLVideoElement>(
HTMLVideoElement,
HTMLMediaElement,
EventTarget
) {}
export class AudioApiProxy extends MediaApiProxyMixin<HTMLAudioElement>(
HTMLAudioElement,
HTMLMediaElement,
EventTarget
) {}
+10 -2
View File
@@ -1,5 +1,4 @@
import type { AnySlice, Slice, Store, UnionSliceState } from '@videojs/store';
import type {
MediaBufferState,
MediaControlsState,
@@ -11,7 +10,16 @@ import type {
MediaVolumeState,
} from '../../core/media/state';
export interface Media extends HTMLMediaElement {}
type WithOptional<Required, Full> = Required & Partial<Omit<Full, keyof Required>>;
export type MediaBaseApi = {
play: () => Promise<void>;
paused: boolean;
};
export type MediaApi = WithOptional<MediaBaseApi, HTMLVideoElement>;
export type Media = MediaApi | HTMLMediaElement | HTMLAudioElement | HTMLVideoElement | null;
export interface MediaContainer extends HTMLElement {}