diff --git a/internal/design/media-api.md b/internal/design/media-api.md new file mode 100644 index 00000000..6b8a95c6 --- /dev/null +++ b/internal/design/media-api.md @@ -0,0 +1,223 @@ +# RFC: Unified Media API + +## Summary + +This RFC proposes a **unified media API** that extends the [HTMLMediaElement](https://html.spec.whatwg.org/multipage/media.html#htmlmediaelement) to support different media types (video, HLS, DASH, YouTube, etc.) in a consistent way. The API is designed to: + +- **Extend** the existing HTMLMediaElement surface rather than replace it. Implementations may expose the **full** HTMLMediaElement surface or a **subset** of it; the unified contract is "HTMLMediaElement-like" where the subset is sufficient for the integration (e.g. play/pause, currentTime, seekable). +- **Support multiple integration points:** for HTML, web components that implement this API; for React and other frameworks, an API that extends `EventTarget` and exposes the same surface (full or subset). +- **Add custom extensions** for behavior not covered by HTMLMediaElement, specifically: + - **Stream type** — distinguish live vs. on-demand content. + - **Live edge** — model the “live edge” of live streams (window, seekable end, seek-to-live). + - **Renditions list** — discover and select video/audio quality levels (renditions) for HTTP Adaptive Streaming (HAS). + +## Motivation + +## Unified control across media types + +Players today deal with many sources: native ` + + + `; +} + +type Constructor = { + new (...args: any[]): 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 (...args: any[]): T; +}; + +export type CustomVideoElement = CustomMediaElementConstructor; +export 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 } +): 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); + 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-expect-error + const natAttrs = nativeElTest?.constructor?.observedAttributes ?? []; + + return [...natAttrs, ...Attributes]; + } + + static #define(): void { + if (CustomMedia.#isDefined) return; + CustomMedia.#isDefined = true; + + const propsToAttrs = new Set(CustomMedia.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 CustomMedia.prototype) continue; + + if (typeof nativeElTest[prop] === 'function') { + // Function + // @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(); + }; + } 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]; + }, + }; + + 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; + } + }; + } + + Object.defineProperty(CustomMedia.prototype, prop, config); + } + } + } + + // Private fields + #isInit = false; + #nativeEl: HTMLVideoElement | HTMLAudioElement | null = null; + #childMap = new Map(); + #childObserver?: MutationObserver; + + get: ((prop: string) => any) | undefined; + set: ((prop: string, val: any) => void) | undefined; + call: ((prop: string, ...args: any[]) => any) | undefined; + + // 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: 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?.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); + } + + // 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 (const prop of nativeElProps) { + // @ts-expect-error + this.#upgradeProperty(prop); + } + + this.#childObserver = new MutationObserver(this.#syncMediaChildAttribute.bind(this)); + this.shadowRoot!.addEventListener('slotchange', () => this.#syncMediaChildren()); + this.#syncMediaChildren(); + + for (const type of (this.constructor as typeof CustomMedia).Events) { + this.shadowRoot!.addEventListener(type, this, true); + } + } + + handleEvent(event: Event): void { + 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'; + } + } + + #upgradeProperty(this: typeof nativeElTest, prop: keyof typeof nativeElTest) { + // Sets properties that are set before the custom element is upgraded. + // https://web.dev/custom-elements-best-practices/#make-properties-lazy + if (Object.hasOwn(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. + // @ts-expect-error + this[prop] = value; + } + } + + 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 { + let html = ''; + for (const key in attrs) { + // Skip forwarding non native video attributes. + if (!Attributes.includes(key as (typeof Attributes)[number])) continue; + + const value = attrs[key]; + if (value === '') html += ` ${key}`; + else html += ` ${key}="${value}"`; + } + return html; +} + +/** + * Helper function to convert NamedNodeMap to a plain object. + */ +function namedNodeMapToObject(namedNodeMap: NamedNodeMap): Record { + const obj: Record = {}; + for (const 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/html/src/ui/hls-video/hls-video-element.ts b/packages/html/src/ui/hls-video/hls-video-element.ts new file mode 100644 index 00000000..4c1feed3 --- /dev/null +++ b/packages/html/src/ui/hls-video/hls-video-element.ts @@ -0,0 +1,29 @@ +import { HlsMediaMixin } from '@videojs/core/dom/media/hls'; +import { CustomMediaMixin } from '../custom-media-element'; + +export class HlsVideo extends HlsMediaMixin(CustomMediaMixin(HTMLElement, { tag: 'video' })) { + static getTemplateHTML(attrs: Record): string { + const { src, ...rest } = attrs; + // biome-ignore lint/complexity/noThisInStatic: intentional use of super + return super.getTemplateHTML(rest); + } + + constructor() { + super(); + // TODO: If we like to support native media elements that + // are appended after the custom element is created, we need to + // attach the native element to the Media API after the native element + // is appended to the DOM. This is currently not supported. + this.attach(this.nativeEl); + } + + attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string | null): void { + if (attrName !== 'src') { + super.attributeChangedCallback(attrName, oldValue, newValue); + } + + if (attrName === 'src' && oldValue !== newValue) { + this.src = newValue ?? ''; + } + } +} diff --git a/packages/react/package.json b/packages/react/package.json index b28efe09..3351cfa0 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -17,6 +17,11 @@ "development": "./dist/dev/index.js", "default": "./dist/default/index.js" }, + "./media/*": { + "types": "./dist/dev/media/*/index.d.ts", + "development": "./dist/dev/media/*/index.js", + "default": "./dist/default/media/*/index.js" + }, "./*.css": "./dist/default/presets/*.css", "./audio": { "types": "./dist/dev/presets/audio/index.d.ts", diff --git a/packages/react/src/media/hls-video/index.tsx b/packages/react/src/media/hls-video/index.tsx new file mode 100644 index 00000000..6c2a6107 --- /dev/null +++ b/packages/react/src/media/hls-video/index.tsx @@ -0,0 +1,27 @@ +import { HlsMedia } from '@videojs/core/dom/media/hls'; +import type { PropsWithChildren, VideoHTMLAttributes } from 'react'; +import { forwardRef, useEffect, useMemo } from 'react'; +import { useMediaRegistration } from '../../player/context'; +import { attachMediaElement } from '../../utils/attach-media-element'; +import { mediaProps } from '../../utils/media-props'; +import { useComposedRefs } from '../../utils/use-composed-refs'; + +export type HlsVideoProps = PropsWithChildren>; + +export const HlsVideo = forwardRef(({ children, ...props }, ref) => { + const mediaApi = useMemo(() => new HlsMedia(), []); + const setMedia = useMediaRegistration(); + + useEffect(() => { + setMedia?.(mediaApi); + }, [mediaApi, setMedia]); + + const composedRef = useComposedRefs(attachMediaElement(mediaApi), ref); + return ( + + ); +}); + +export default HlsVideo; diff --git a/packages/react/src/utils/attach-media-element.ts b/packages/react/src/utils/attach-media-element.ts new file mode 100644 index 00000000..84a2c414 --- /dev/null +++ b/packages/react/src/utils/attach-media-element.ts @@ -0,0 +1,13 @@ +import type { MediaApiProxy } from '@videojs/core/dom'; + +export function attachMediaElement(media: MediaApiProxy): (element: T | null) => void { + return (element: T | null) => { + if (element) { + media.attach(element); + } else { + media.detach(); + } + // React 19+ accepts a cleanup function as the return value + return () => media.detach(); + }; +} diff --git a/packages/react/src/utils/media-props.ts b/packages/react/src/utils/media-props.ts new file mode 100644 index 00000000..73ac956c --- /dev/null +++ b/packages/react/src/utils/media-props.ts @@ -0,0 +1,20 @@ +import type { MediaApi } from '@videojs/core/dom'; +import type { VideoHTMLAttributes } from 'react'; + +interface VideoProps extends VideoHTMLAttributes {} + +export function mediaProps(media: MediaApi, props: VideoProps) { + const { src, ...remainingProps } = props; + + // Preload can still be passed as a prop to the native media element + if (props.preload && media.preload !== props.preload) { + media.preload = props.preload as '' | 'none' | 'metadata' | 'auto'; + } + + if (media.src !== src) { + media.src = src ?? ''; + } + + // The remaining props are passed to the native media element + return remainingProps; +} diff --git a/packages/react/tsdown.config.ts b/packages/react/tsdown.config.ts index faedff72..4b141173 100644 --- a/packages/react/tsdown.config.ts +++ b/packages/react/tsdown.config.ts @@ -7,7 +7,7 @@ type BuildMode = 'dev' | 'default'; const buildModes: BuildMode[] = ['dev', 'default']; const createConfig = (mode: BuildMode): UserConfig => ({ - entry: 'src/**/index.ts', + entry: 'src/**/index.{ts,tsx}', platform: 'browser', format: 'es', sourcemap: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b13266b..fbd7d644 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,6 +176,9 @@ importers: '@videojs/utils': specifier: workspace:* version: link:../utils + hls.js: + specifier: ^1.6.7 + version: 1.6.15 devDependencies: jsdom: specifier: ^26.1.0