fix(site): surface native media properties on element reference pages (#1722)

This commit is contained in:
Darius Cepulis
2026-07-15 09:31:13 -07:00
committed by GitHub
parent a9a09a7e52
commit 0dd84b819e
19 changed files with 1169 additions and 444 deletions
@@ -24,7 +24,14 @@ import * as path from 'node:path';
import * as ts from 'typescript';
import * as tae from 'typescript-api-extractor';
import { extractCSSVars } from './css-vars-handler.js';
import type { HostPropertyDef, MediaElementReference, MediaElementResult, MediaEventDef } from './pipeline.js';
import type {
HostPropertyDef,
MediaElementReference,
MediaElementResult,
MediaEventDef,
MediaTargetTag,
ReactMediaReference,
} from './pipeline.js';
// ─── Constants ──────────────────────────────────────────────────────
@@ -46,6 +53,12 @@ interface MediaElementSource {
hostFilePath: string;
hostClassName: string;
mediaType: 'video' | 'audio';
targetTag: MediaTargetTag;
}
interface StaticMediaProperty {
property: string;
attribute: string;
}
// ─── Module Resolution ───────────────────────────────────────────────
@@ -206,6 +219,7 @@ function parseDefineFile(
hostFilePath,
hostClassName: hostInfo.hostClassName,
mediaType: hostInfo.mediaType,
targetTag: hostInfo.targetTag,
};
}
@@ -220,9 +234,10 @@ function stripElementSuffix(name: string): string {
function parseCustomMediaElementCall(
sourceFile: ts.SourceFile,
className: string
): { hostClassName: string; mediaType: 'video' | 'audio' } | null {
): { hostClassName: string; mediaType: 'video' | 'audio'; targetTag: MediaTargetTag } | null {
let hostClassName: string | undefined;
let mediaType: 'video' | 'audio' | undefined;
let targetTag: MediaTargetTag | undefined;
ts.forEachChild(sourceFile, (node) => {
if (!ts.isClassDeclaration(node)) return;
@@ -236,6 +251,15 @@ function parseCustomMediaElementCall(
findCustomMediaElement(extendsExpr);
});
// Media implementations may put template behavior on a local base class
// and export a thin mixed-in subclass (VimeoVideo is the real-world case).
// Each media module owns a single CustomMediaElement composition, so use
// that composition when it is not directly present in the exported class's
// extends expression.
if (!hostClassName || !mediaType || !targetTag) {
findCustomMediaElement(sourceFile);
}
function findCustomMediaElement(node: ts.Node): void {
if (
ts.isCallExpression(node) &&
@@ -243,10 +267,11 @@ function parseCustomMediaElementCall(
node.expression.text === 'CustomMediaElement'
) {
if (node.arguments.length >= 2) {
// First arg: media type string literal ('video' or 'audio')
// First arg: rendered target tag (`video`, `audio`, or `iframe`).
const tagArg = node.arguments[0]!;
if (ts.isStringLiteral(tagArg)) {
mediaType = tagArg.text === 'audio' ? 'audio' : 'video';
if (ts.isStringLiteral(tagArg) && ['video', 'audio', 'iframe'].includes(tagArg.text)) {
targetTag = tagArg.text as MediaTargetTag;
mediaType = targetTag === 'audio' ? 'audio' : 'video';
}
// Second arg: host class identifier
const hostArg = node.arguments[1]!;
@@ -259,8 +284,8 @@ function parseCustomMediaElementCall(
ts.forEachChild(node, findCustomMediaElement);
}
if (!hostClassName || !mediaType) return null;
return { hostClassName, mediaType };
if (!hostClassName || !mediaType || !targetTag) return null;
return { hostClassName, mediaType, targetTag };
}
// ─── Host Property Extraction ───────────────────────────────────────
@@ -931,14 +956,14 @@ function serializeDefaultValue(
// ─── Shared Data Extraction ──────────────────────────────────────────
/**
* Extract native attribute names from the `static properties` object inside
* the CustomMediaElement factory. Each key maps to an attribute name via
* `props[key].attribute ?? key.toLowerCase()`.
* Extract property-to-attribute mappings from the `static properties` object
* inside the CustomMediaElement factory. The property name is needed to
* classify standard attributes separately from Video.js-specific ones.
*/
function extractStaticProperties(filePath: string): string[] {
function extractStaticProperties(filePath: string): StaticMediaProperty[] {
const content = fs.readFileSync(filePath, 'utf-8');
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
const attributes: string[] = [];
const properties: StaticMediaProperty[] = [];
function visit(node: ts.Node): void {
// Look for: static properties = { ... }
@@ -971,7 +996,7 @@ function extractStaticProperties(filePath: string): string[] {
}
}
attributes.push(attrName);
properties.push({ property: propName, attribute: attrName });
}
return;
}
@@ -979,7 +1004,146 @@ function extractStaticProperties(filePath: string): string[] {
}
visit(sourceFile);
return attributes;
return properties;
}
// ─── React Surface Extraction ──────────────────────────────────────
/**
* Extract the public React surface from the matching media component.
*
* Convention:
* - `forwardRef<HTML*Element, *Props>` declares the public ref target.
* - extending `VideoHTMLAttributes` / `AudioHTMLAttributes` opts into the
* native React DOM props.
* - the defaults object passed to `useSyncProps` is the runtime source of
* truth for Video.js-specific props.
*/
function extractReactReference(
monorepoRoot: string,
source: MediaElementSource,
compilerOptions: ts.CompilerOptions,
propertyDefinitions: Record<string, HostPropertyDef>
): ReactMediaReference | undefined {
const mediaDirectory = path.basename(path.dirname(source.mediaFilePath));
const reactFilePath = path.join(monorepoRoot, 'packages/react/src/media', mediaDirectory, 'index.tsx');
if (!fs.existsSync(reactFilePath)) return undefined;
const content = fs.readFileSync(reactFilePath, 'utf-8');
const sourceFile = ts.createSourceFile(reactFilePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const propsInterfaceName = `${source.className}Props`;
let target: MediaTargetTag | undefined;
let acceptsNativeProps = false;
let defaultsName: string | undefined;
function visit(node: ts.Node): void {
if (ts.isInterfaceDeclaration(node) && node.name.text === propsInterfaceName) {
acceptsNativeProps =
node.heritageClauses?.some((clause) =>
clause.types.some((type) => /(?:Video|Audio)HTMLAttributes/.test(type.getText(sourceFile)))
) ?? false;
}
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === source.className &&
node.initializer &&
ts.isCallExpression(node.initializer) &&
ts.isIdentifier(node.initializer.expression) &&
node.initializer.expression.text === 'forwardRef'
) {
const refType = node.initializer.typeArguments?.[0]?.getText(sourceFile);
if (refType === 'HTMLVideoElement') target = 'video';
if (refType === 'HTMLAudioElement') target = 'audio';
if (refType === 'HTMLIFrameElement') target = 'iframe';
}
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === 'useSyncProps' &&
node.arguments.length >= 3
) {
const defaultsArg = node.arguments[2]!;
if (ts.isIdentifier(defaultsArg)) defaultsName = defaultsArg.text;
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
if (!target) return undefined;
const props: Record<string, HostPropertyDef> = {};
if (defaultsName) {
const resolved = resolveConstObjectLiteral(defaultsName, sourceFile, reactFilePath, compilerOptions);
if (resolved) {
const defaultValues = resolveObjectLiteralEntries(
resolved.objectLiteral,
resolved.sourceFile,
resolved.filePath,
compilerOptions,
new Set()
);
const names = resolveObjectLiteralPropertyNames(
resolved.objectLiteral,
resolved.sourceFile,
resolved.filePath,
compilerOptions,
new Set()
);
for (const name of [...names].sort()) {
const definition = propertyDefinitions[name];
const prop: HostPropertyDef = definition
? { ...definition, readonly: false }
: { type: 'unknown', readonly: false };
const defaultValue = defaultValues.get(name);
if (defaultValue !== undefined) prop.default = defaultValue;
props[name] = prop;
}
}
}
return { target, acceptsNativeProps, props };
}
/** Collect object-literal keys, including keys whose defaults are not serializable. */
function resolveObjectLiteralPropertyNames(
objectLiteral: ts.ObjectLiteralExpression,
sourceFile: ts.SourceFile,
filePath: string,
compilerOptions: ts.CompilerOptions,
visited: Set<string>
): Set<string> {
const names = new Set<string>();
for (const prop of objectLiteral.properties) {
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
names.add(prop.name.text);
continue;
}
if (ts.isSpreadAssignment(prop) && ts.isIdentifier(prop.expression)) {
const resolved = resolveConstObjectLiteral(prop.expression.text, sourceFile, filePath, compilerOptions);
if (!resolved) continue;
const visitKey = `${resolved.filePath}::${prop.expression.text}`;
if (visited.has(visitKey)) continue;
visited.add(visitKey);
for (const name of resolveObjectLiteralPropertyNames(
resolved.objectLiteral,
resolved.sourceFile,
resolved.filePath,
compilerOptions,
visited
)) {
names.add(name);
}
}
}
return names;
}
// ─── Method Extraction ───────────────────────────────────────────────
@@ -1237,6 +1401,52 @@ function parseFiresTagComment(tag: ts.JSDocTag): { name: string; description: st
function scanForDispatchEvents(sourceFile: ts.SourceFile, events: Set<string>): void {
function visit(node: ts.Node): void {
if (
ts.isForOfStatement(node) &&
ts.isVariableDeclarationList(node.initializer) &&
ts.isArrayLiteralExpression(node.expression)
) {
const declaration = node.initializer.declarations[0];
const loopName = declaration && ts.isIdentifier(declaration.name) ? declaration.name.text : undefined;
let dispatchesLoopValue = false;
if (loopName) {
function findLoopDispatch(child: ts.Node): void {
if (
ts.isNewExpression(child) &&
ts.isIdentifier(child.expression) &&
child.expression.text === 'Event' &&
child.arguments?.[0] &&
ts.isIdentifier(child.arguments[0]) &&
child.arguments[0].text === loopName
) {
dispatchesLoopValue = true;
}
ts.forEachChild(child, findLoopDispatch);
}
findLoopDispatch(node.statement);
}
if (dispatchesLoopValue) {
for (const element of node.expression.elements) {
if (ts.isStringLiteral(element)) events.add(element.text);
}
}
}
// Adapter implementations commonly use a local `emit` helper to bridge
// events from a third-party player (for example Vimeo). Literal calls are
// still an unambiguous declaration of the events the adapter implements.
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === 'emit' &&
node.arguments[0] &&
ts.isStringLiteral(node.arguments[0])
) {
events.add(node.arguments[0].text);
}
if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
@@ -1294,7 +1504,7 @@ export function generateMediaElementReferences(monorepoRoot: string): MediaEleme
if (!fs.existsSync(customMediaPath)) return [];
// Read shared data
const allAttributes = extractStaticProperties(customMediaPath);
const staticProperties = extractStaticProperties(customMediaPath);
// Extract events from capability contract types
const mediaTypesPath = path.join(monorepoRoot, 'packages/core/src/core/media/types.ts');
@@ -1334,7 +1544,13 @@ export function generateMediaElementReferences(monorepoRoot: string): MediaEleme
lib: dedupeStrings([...(compilerOptions.lib ?? []), 'lib.dom.d.ts']),
};
const program = ts.createProgram(
dedupeStrings([customMediaPath, ...sources.map((s) => s.hostFilePath)]),
dedupeStrings([
customMediaPath,
mediaHostPath,
videoHostPath,
audioHostPath,
...sources.map((s) => s.hostFilePath),
]),
programOptions
);
const checker = program.getTypeChecker();
@@ -1347,6 +1563,36 @@ export function generateMediaElementReferences(monorepoRoot: string): MediaEleme
? collectNativeMemberNames(program, customMediaSourceFile)
: new Set<string>();
const baseHostProperties = extractHostProperties(mediaHostPath, 'HTMLMediaElementHost', compilerOptions, nativeNames);
const videoHostProperties = extractHostProperties(
videoHostPath,
'HTMLVideoElementHost',
compilerOptions,
nativeNames
);
const audioHostProperties = extractHostProperties(
audioHostPath,
'HTMLAudioElementHost',
compilerOptions,
nativeNames
);
function fillInferredTypes(properties: Record<string, HostPropertyDef>, filePath: string, className: string): void {
const inferredTypes = resolveInferredTypes(filePath, className, program, checker);
for (const [name, def] of Object.entries(properties)) {
if (def.type === 'unknown' && inferredTypes.has(name)) {
def.type = inferredTypes.get(name)!;
}
}
}
fillInferredTypes(baseHostProperties, mediaHostPath, 'HTMLMediaElementHost');
fillInferredTypes(videoHostProperties, videoHostPath, 'HTMLVideoElementHost');
fillInferredTypes(audioHostProperties, audioHostPath, 'HTMLAudioElementHost');
const videoBaseSurface = { ...baseHostProperties, ...videoHostProperties };
const audioBaseSurface = { ...baseHostProperties, ...audioHostProperties };
const videoCSSVars: Record<string, { description: string }> = {};
if (videoCSSVarsRaw) {
for (const v of videoCSSVarsRaw.vars) {
@@ -1371,25 +1617,39 @@ export function generateMediaElementReferences(monorepoRoot: string): MediaEleme
nativeNames
);
// The AST walk only reads explicit return-type annotations; getters without
// one fall back to the literal string 'unknown'. Fill those gaps from the
// type checker, which infers the real type across the mixin chain. Authored
// annotations are left untouched.
const inferredTypes = resolveInferredTypes(source.hostFilePath, source.hostClassName, program, checker);
for (const [name, def] of Object.entries(hostProperties)) {
if (def.type === 'unknown' && inferredTypes.has(name)) {
def.type = inferredTypes.get(name)!;
fillInferredTypes(hostProperties, source.hostFilePath, source.hostClassName);
const baseSurface =
source.targetTag === 'video' ? videoBaseSurface : source.targetTag === 'audio' ? audioBaseSurface : {};
const publicProperties = { ...baseSurface, ...hostProperties };
const propertyDefinitions: Record<string, HostPropertyDef> = {};
for (const [name, definition] of Object.entries(baseSurface)) {
if (!definition.overridesNative) propertyDefinitions[name] = definition;
}
Object.assign(propertyDefinitions, hostProperties);
const standardAttributes: string[] = [];
const customAttributes: Record<string, HostPropertyDef> = {};
for (const { property, attribute } of staticProperties) {
const definition = publicProperties[property];
if (source.targetTag === 'iframe') {
// Embed attributes only have media semantics when the synthetic host
// implements the corresponding property. Unmatched shared attributes
// are inert because iframe targets do not receive attribute forwarding.
if (definition) customAttributes[attribute] = { ...definition, readonly: false };
continue;
}
if (definition && !definition.overridesNative) {
customAttributes[attribute] = { ...definition, readonly: false };
} else {
standardAttributes.push(attribute);
}
}
// Native attributes are the COMPLETE markup-settable set from `static
// properties`. Host-owned names (src/preload/stream-type) intentionally
// overlap with hostProperties — this mirrors MDN's content-attribute vs
// IDL-property model: the same name is both a settable attribute and a
// richer JS property.
const nativeAttributes = [...allAttributes];
const cssCustomProperties = source.mediaType === 'video' ? videoCSSVars : audioCSSVars;
const cssCustomProperties =
source.targetTag === 'video' ? videoCSSVars : source.targetTag === 'audio' ? audioCSSVars : {};
// Walk the host's mixin/parent chain collecting `@fires` descriptions. An
// event is documented as element-specific iff it carries a `@fires` tag —
@@ -1397,8 +1657,24 @@ export function generateMediaElementReferences(monorepoRoot: string): MediaEleme
// DOM events are never tagged, and a tagged event stays documented even when
// it also lives in the typed media events contract (e.g. streamtypechange).
const fires = new Map<string, string>();
extractDispatchedEvents(source.hostFilePath, source.hostClassName, compilerOptions, new Set(), new Set(), fires);
const elementSpecific: MediaEventDef[] = [...fires.keys()].sort().map((name) => {
const dispatchedEvents = new Set<string>();
extractDispatchedEvents(
source.hostFilePath,
source.hostClassName,
compilerOptions,
new Set(),
dispatchedEvents,
fires
);
const contractEvents = source.mediaType === 'video' ? videoEvents : audioEvents;
const contractEventNames = new Set(contractEvents);
const customEventNamesForElement = new Set(fires.keys());
if (source.targetTag === 'iframe') {
for (const name of dispatchedEvents) {
if (!contractEventNames.has(name)) customEventNamesForElement.add(name);
}
}
const customEvents: MediaEventDef[] = [...customEventNamesForElement].sort().map((name) => {
const def: MediaEventDef = { name };
const description = fires.get(name);
if (description) def.description = description;
@@ -1411,22 +1687,52 @@ export function generateMediaElementReferences(monorepoRoot: string): MediaEleme
// capability interfaces — these are never native, even on elements that
// don't fire them (e.g. dash-video has no streamType, so streamtypechange
// appears nowhere).
const elementSpecificNames = new Set(elementSpecific.map((e) => e.name));
const native = (source.mediaType === 'video' ? videoEvents : audioEvents).filter(
(n) => !elementSpecificNames.has(n) && !customEventNames.has(n)
const customEventSet = new Set(customEvents.map((event) => event.name));
const standardEvents = contractEvents.filter(
(name) =>
!customEventSet.has(name) &&
!customEventNames.has(name) &&
(source.targetTag !== 'iframe' || dispatchedEvents.has(name))
);
const methods = source.mediaType === 'video' ? videoMethods : audioMethods;
const baseMethodNames =
source.targetTag === 'video' ? videoMethods : source.targetTag === 'audio' ? audioMethods : [];
const methods = mergeMethodNames(
baseMethodNames,
extractPublicMethodNames(source.hostFilePath, source.hostClassName)
);
const nativeProperties = Object.entries(baseSurface)
.filter(([name, definition]) => definition.overridesNative && !(name in hostProperties))
.map(([name]) => name)
.sort();
const react = extractReactReference(monorepoRoot, source, compilerOptions, publicProperties);
const reference: MediaElementReference = {
name: source.className,
tagName: source.tagName,
mediaType: source.mediaType,
hostProperties,
nativeAttributes,
events: { native, elementSpecific },
methods,
cssCustomProperties,
platforms: {
html: {
target: source.targetTag,
attributes: {
standard: standardAttributes.sort(),
custom: customAttributes,
},
properties: {
definitions: propertyDefinitions,
native: nativeProperties,
},
events: {
standard: standardEvents,
custom: customEvents,
},
methods,
cssCustomProperties,
},
...(react ? { react } : {}),
},
};
results.push({ name: source.className, reference });
+29 -7
View File
@@ -668,18 +668,40 @@ export interface MediaEventDef {
description?: string;
}
export type MediaTargetTag = 'video' | 'audio' | 'iframe';
export interface HtmlMediaReference {
target: MediaTargetTag;
attributes: {
standard: string[];
custom: Record<string, HostPropertyDef>;
};
properties: {
definitions: Record<string, HostPropertyDef>;
native: string[];
};
events: {
standard: string[];
custom: MediaEventDef[];
};
methods: string[];
cssCustomProperties: Record<string, { description: string }>;
}
export interface ReactMediaReference {
target: MediaTargetTag;
acceptsNativeProps: boolean;
props: Record<string, HostPropertyDef>;
}
export interface MediaElementReference {
name: string;
tagName: string;
mediaType: 'video' | 'audio';
hostProperties: Record<string, HostPropertyDef>;
nativeAttributes: string[];
events: {
native: string[];
elementSpecific: MediaEventDef[];
platforms: {
html: HtmlMediaReference;
react?: ReactMediaReference;
};
methods: string[];
cssCustomProperties: Record<string, { description: string }>;
}
export interface MediaElementResult {
@@ -1151,8 +1151,8 @@ describe('Preset pipeline (end-to-end)', () => {
// MEDIA ELEMENT PIPELINE
// ═══════════════════════════════════════════════════════════════════════
//
// Media elements are custom elements that wrap native <video>/<audio> with
// streaming hosts (HLS, DASH, etc.). They are discovered from
// Media elements are custom elements that adapt native <video>/<audio> targets
// or embedded players. They are discovered from
// packages/html/src/define/media/*.ts by looking for files that declare a
// class with `static tagName`.
//
@@ -1160,7 +1160,9 @@ describe('Preset pipeline (end-to-end)', () => {
// - Tag name from the element class's static tagName
// - Host properties by following the CustomMediaElement(tag, Host) call to the
// host class and walking its getter/setter pairs
// - Shared native attributes from static properties, events, and CSS vars
// - Standard/custom attributes from static properties and host accessors
// - Platform metadata from the matching React component conventions
// - Events and CSS vars for the HTML custom element
// - JSDoc descriptions from host getter/setter pairs
//
// Key behaviors:
@@ -1168,13 +1170,16 @@ describe('Preset pipeline (end-to-end)', () => {
// - Exclusion: container.ts (re-exports, no inline class), background-video.ts
// (no CustomMediaElement — uses MediaAttachMixin(HTMLElement) directly)
// - Host inheritance: child host extends parent, builder walks the chain
// - Attribute overlap: nativeAttributes is the COMPLETE markup-settable set
// (no dedup). Host-owned names (e.g., src, preload) appear in BOTH
// hostProperties and nativeAttributes (content-attribute vs IDL-property).
// - Attribute classification: standard attributes remain an MDN-linked list;
// Video.js-specific attributes use their corresponding host definitions.
// - Methods: native media methods are extracted ONCE per media type from the
// shared base host classes (media-host + video-host/audio-host).
// - Event buckets: element-specific (@fires-tagged) events live ONLY in
// elementSpecific, never in native.
// - Properties: the inherited native surface is compact, while source-authored
// definitions retain types, defaults, and descriptions.
// - Event buckets: custom (@fires-tagged) events live ONLY in `custom`, never
// in the standard MDN-linked list.
// - React: forwardRef and useSyncProps conventions produce the ref target and
// Video.js-specific prop table without per-element configuration.
describe('Media element pipeline (end-to-end)', () => {
const results = generateMediaElementReferences(FIXTURE_ROOT);
@@ -1190,7 +1195,7 @@ describe('Media element pipeline (end-to-end)', () => {
describe('Discovery', () => {
it('discovers media elements from define/media/ files', () => {
const names = results.map((r) => r.name).sort();
expect(names).toEqual(['ComplexVideo', 'ExtendingVideo', 'MixinVideo', 'SimpleVideo', 'SpfAudio']);
expect(names).toEqual(['ComplexVideo', 'EmbedVideo', 'ExtendingVideo', 'MixinVideo', 'SimpleVideo', 'SpfAudio']);
});
it('excludes container (re-export, not inline class declaration)', () => {
@@ -1204,7 +1209,7 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('produces one result per media element', () => {
expect(results.length).toBe(5);
expect(results.length).toBe(6);
});
});
@@ -1225,7 +1230,7 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('extracts host properties with types and readonly flags', () => {
const props = findElement('SimpleVideo')!.reference.hostProperties;
const props = findElement('SimpleVideo')!.reference.platforms.html.properties.definitions;
// src: read-write string
expect(props.src).toMatchObject({
@@ -1243,19 +1248,15 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('excludes host lifecycle methods (attach, detach, destroy)', () => {
const props = findElement('SimpleVideo')!.reference.hostProperties;
const props = findElement('SimpleVideo')!.reference.platforms.html.properties.definitions;
expect(props.attach).toBeUndefined();
expect(props.detach).toBeUndefined();
expect(props.destroy).toBeUndefined();
});
it('includes the COMPLETE set of native attributes from static properties', () => {
it('separates standard attributes from Video.js-specific attributes', () => {
const ref = findElement('SimpleVideo')!.reference;
// nativeAttributes is the full markup-settable set from `static
// properties` — no dedup against host props. `src` is settable as an
// attribute even though the host also exposes it as a richer property,
// so it appears in BOTH places (MDN content-attribute vs IDL-property).
expect(ref.nativeAttributes).toEqual(
expect(ref.platforms.html.attributes.standard).toEqual(
expect.arrayContaining([
'autoplay',
'controls',
@@ -1267,14 +1268,37 @@ describe('Media element pipeline (end-to-end)', () => {
'preload',
])
);
expect(ref.nativeAttributes).toContain('src');
expect(ref.platforms.html.attributes.standard).toContain('src');
expect(ref.platforms.html.attributes.standard).not.toContain('stream-type');
expect(ref.platforms.html.attributes.custom['stream-type']).toMatchObject({
type: 'string',
readonly: false,
});
expect(ref.platforms.html.attributes.custom['stream-type'].description).toContain('Current stream type');
});
it('extracts native media methods from the shared base host classes', () => {
const ref = findElement('SimpleVideo')!.reference;
// Video methods = media-host methods + video-host methods, deduped + sorted.
// Lifecycle methods (attach/detach/destroy) and accessors are excluded.
expect(ref.methods).toEqual(['canPlayType', 'load', 'pause', 'play', 'requestFullscreen']);
expect(ref.platforms.html.methods).toEqual(['canPlayType', 'load', 'pause', 'play', 'requestFullscreen']);
});
it('extracts native passthrough properties from the shared base host classes', () => {
const ref = findElement('SimpleVideo')!.reference;
// Video native properties = media-host + video-host accessors, filtered to
// genuine native members and deduped against hostProperties. `currentTime`
// and `volume` come from media-host; `videoWidth` is video-only.
expect(ref.platforms.html.properties.native).toEqual(['currentTime', 'videoWidth', 'volume']);
// Video.js-specific base accessors receive full definitions instead.
expect(ref.platforms.html.properties.native).not.toContain('streamType');
expect(ref.platforms.html.properties.native).not.toContain('isFullscreen');
expect(ref.platforms.html.properties.definitions.streamType).toBeDefined();
expect(ref.platforms.html.properties.definitions.isFullscreen).toBeDefined();
// `src` is native but re-declared in hostProperties → deduped out (shown in
// the rich table instead).
expect(ref.platforms.html.properties.definitions.src).toBeDefined();
expect(ref.platforms.html.properties.native).not.toContain('src');
});
it('includes events derived from VideoEvents capability contracts', () => {
@@ -1284,7 +1308,7 @@ describe('Media element pipeline (end-to-end)', () => {
// Video.js events from MediaStreamTypeEvents/MediaLiveEvents
// (streamtypechange) are NOT native and are excluded here — they only
// appear in elementSpecific, and only on elements that @fires them.
expect(ref.events.native).toEqual([
expect(ref.platforms.html.events.standard).toEqual([
'play',
'playing',
'waiting',
@@ -1310,23 +1334,23 @@ describe('Media element pipeline (end-to-end)', () => {
'trackmodechange',
]);
// SimpleHost dispatches no events of its own.
expect(ref.events.elementSpecific).toEqual([]);
expect(ref.platforms.html.events.custom).toEqual([]);
});
it('omits custom events entirely when the element does not @fires them', () => {
// Regression guard: streamtypechange lives in the VideoEvents contract via
// MediaStreamTypeEvents, but SimpleVideo has no @fires tag for it (and no
// streamType capability). A custom event must never leak into `native`
// streamType event documentation). A custom event must never leak into the standard list
// (which points readers at MDN) — with no @fires it appears in NEITHER
// bucket. Mirrors dash-video / simple-hls-video in the real monorepo.
const ref = findElement('SimpleVideo')!.reference;
expect(ref.events.native).not.toContain('streamtypechange');
const elementSpecificNames = ref.events.elementSpecific.map((e) => e.name);
expect(ref.platforms.html.events.standard).not.toContain('streamtypechange');
const elementSpecificNames = ref.platforms.html.events.custom.map((e) => e.name);
expect(elementSpecificNames).not.toContain('streamtypechange');
});
it('includes CSS custom properties from VideoCSSVars', () => {
const css = findElement('SimpleVideo')!.reference.cssCustomProperties;
const css = findElement('SimpleVideo')!.reference.platforms.html.cssCustomProperties;
expect(css['--media-object-fit']).toEqual({
description: 'Object fit for the video.',
});
@@ -1352,12 +1376,13 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('extracts all host properties', () => {
const props = findElement('ComplexVideo')!.reference.hostProperties;
const props = findElement('ComplexVideo')!.reference.platforms.html.properties.definitions;
const propNames = Object.keys(props).sort();
expect(propNames).toEqual([
'config',
'debug',
'engine',
'isFullscreen',
'preferPlayback',
'preload',
'src',
@@ -1367,7 +1392,7 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('extracts JSDoc descriptions from host getters', () => {
const props = findElement('ComplexVideo')!.reference.hostProperties;
const props = findElement('ComplexVideo')!.reference.platforms.html.properties.definitions;
expect(props.type.description).toBe('Explicit source type. When unset, inferred from the source URL extension.');
expect(props.preferPlayback.description).toBe("Whether to prefer `'mse'` or `'native'` playback.");
expect(props.debug.description).toBe('Enable debug logging.');
@@ -1375,7 +1400,7 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('marks readonly properties correctly', () => {
const props = findElement('ComplexVideo')!.reference.hostProperties;
const props = findElement('ComplexVideo')!.reference.platforms.html.properties.definitions;
// engine: getter only → readonly
expect(props.engine.readonly).toBe(true);
// src: getter + setter → not readonly
@@ -1384,7 +1409,7 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('extracts property types', () => {
const props = findElement('ComplexVideo')!.reference.hostProperties;
const props = findElement('ComplexVideo')!.reference.platforms.html.properties.definitions;
expect(props.src.type).toBe('string');
expect(props.debug.type).toBe('boolean');
expect(props.config.type).toContain('Record');
@@ -1395,18 +1420,18 @@ describe('Media element pipeline (end-to-end)', () => {
// src and preload are richer host properties AND genuinely settable as
// markup attributes — the intentional content-attribute vs IDL-property
// overlap. They appear in hostProperties...
expect(ref.hostProperties.src).toBeDefined();
expect(ref.hostProperties.preload).toBeDefined();
expect(ref.platforms.html.properties.definitions.src).toBeDefined();
expect(ref.platforms.html.properties.definitions.preload).toBeDefined();
// ...and ALSO in nativeAttributes (no dedup).
expect(ref.nativeAttributes).toContain('src');
expect(ref.nativeAttributes).toContain('preload');
expect(ref.platforms.html.attributes.standard).toContain('src');
expect(ref.platforms.html.attributes.standard).toContain('preload');
// Other native attrs remain
expect(ref.nativeAttributes).toContain('autoplay');
expect(ref.nativeAttributes).toContain('controls');
expect(ref.platforms.html.attributes.standard).toContain('autoplay');
expect(ref.platforms.html.attributes.standard).toContain('controls');
});
it('extracts defaults from the co-located defaultProps export', () => {
const props = findElement('ComplexVideo')!.reference.hostProperties;
const props = findElement('ComplexVideo')!.reference.platforms.html.properties.definitions;
// Literal values are emitted as source text (strings keep their quotes).
expect(props.src.default).toBe("''");
expect(props.debug.default).toBe('false');
@@ -1422,14 +1447,71 @@ describe('Media element pipeline (end-to-end)', () => {
it('resolves const-object member defaults through imports', () => {
// streamType: MediaStreamTypes.UNKNOWN — the builder resolves the member
// access to its literal value in the imported `as const` object.
const props = findElement('ComplexVideo')!.reference.hostProperties;
const props = findElement('ComplexVideo')!.reference.platforms.html.properties.definitions;
expect(props.streamType.default).toBe("'unknown'");
});
it('omits defaults for properties without a defaultProps entry', () => {
const props = findElement('ComplexVideo')!.reference.hostProperties;
const props = findElement('ComplexVideo')!.reference.platforms.html.properties.definitions;
expect(props.engine.default).toBeUndefined();
});
it('extracts the matching React surface from source conventions', () => {
const react = findElement('ComplexVideo')!.reference.platforms.react;
expect(react).toMatchObject({
target: 'video',
acceptsNativeProps: true,
});
expect(Object.keys(react!.props).sort()).toEqual([
'config',
'debug',
'preferPlayback',
'preload',
'src',
'streamType',
'type',
]);
expect(react!.props.streamType.default).toBe("'unknown'");
expect(react!.props.engine).toBeUndefined();
});
});
// ─────────────────────────────────────────────────────────────────
// EMBED MEDIA ELEMENT: EmbedVideo
// ─────────────────────────────────────────────────────────────────
describe('EmbedVideo (iframe-backed media)', () => {
it('uses the target declared by CustomMediaElement', () => {
const ref = findElement('EmbedVideo')!.reference;
expect(ref.platforms.html.target).toBe('iframe');
expect(ref.platforms.react?.target).toBe('iframe');
});
it('does not invent native video properties, methods, or CSS for an iframe target', () => {
const html = findElement('EmbedVideo')!.reference.platforms.html;
expect(html.properties.native).toEqual([]);
expect(html.methods).toEqual(['play']);
expect(html.cssCustomProperties).toEqual({});
});
it('documents only attributes implemented by the synthetic media host', () => {
const attributes = findElement('EmbedVideo')!.reference.platforms.html.attributes;
expect(attributes.standard).toEqual([]);
expect(Object.keys(attributes.custom).sort()).toEqual(['autoplay', 'src']);
expect(attributes.custom['stream-type']).toBeUndefined();
});
it('documents only events dispatched by the embedded media adapter', () => {
const events = findElement('EmbedVideo')!.reference.platforms.html.events;
expect(events.standard).toEqual(['play', 'waiting', 'loadedmetadata']);
expect(events.custom).toEqual([{ name: 'adapterready' }]);
});
it('extracts custom React props without claiming native media props', () => {
const react = findElement('EmbedVideo')!.reference.platforms.react;
expect(react).toMatchObject({ target: 'iframe', acceptsNativeProps: false });
expect(Object.keys(react!.props).sort()).toEqual(['autoplay', 'src']);
});
});
// ─────────────────────────────────────────────────────────────────
@@ -1448,7 +1530,7 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('includes own properties from ExtendingHost', () => {
const props = findElement('ExtendingVideo')!.reference.hostProperties;
const props = findElement('ExtendingVideo')!.reference.platforms.html.properties.definitions;
expect(props.playbackId).toMatchObject({
type: 'string',
readonly: false,
@@ -1462,7 +1544,7 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('includes inherited properties from ComplexHost', () => {
const props = findElement('ExtendingVideo')!.reference.hostProperties;
const props = findElement('ExtendingVideo')!.reference.platforms.html.properties.definitions;
// These are inherited from ComplexHost
expect(props.src).toBeDefined();
expect(props.type).toBeDefined();
@@ -1473,13 +1555,13 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('child overrides replace parent definitions', () => {
const props = findElement('ExtendingVideo')!.reference.hostProperties;
const props = findElement('ExtendingVideo')!.reference.platforms.html.properties.definitions;
// ExtendingHost overrides debug with different JSDoc
expect(props.debug.description).toBe('Overrides parent debug — adds network logging.');
});
it('inherited readonly flags are preserved', () => {
const props = findElement('ExtendingVideo')!.reference.hostProperties;
const props = findElement('ExtendingVideo')!.reference.platforms.html.properties.definitions;
// engine is readonly in ComplexHost and not overridden
expect(props.engine.readonly).toBe(true);
});
@@ -1487,25 +1569,25 @@ describe('Media element pipeline (end-to-end)', () => {
it('resolves spread defaults through the parent defaultProps import', () => {
// extendingMediaDefaultProps = { ...complexMediaDefaultProps, ... } —
// the builder must follow the spread to the imported object literal.
const props = findElement('ExtendingVideo')!.reference.hostProperties;
const props = findElement('ExtendingVideo')!.reference.platforms.html.properties.definitions;
expect(props.src.default).toBe("''");
expect(props.debug.default).toBe('false');
expect(props.streamType.default).toBe("'unknown'");
});
it('extracts own defaults alongside spread defaults', () => {
const props = findElement('ExtendingVideo')!.reference.hostProperties;
const props = findElement('ExtendingVideo')!.reference.platforms.html.properties.definitions;
expect(props.playbackId.default).toBe("''");
expect(props.maxResolution.default).toBe('1080');
});
it('abbreviates non-empty object defaults', () => {
const props = findElement('ExtendingVideo')!.reference.hostProperties;
const props = findElement('ExtendingVideo')!.reference.platforms.html.properties.definitions;
expect(props.tokens.default).toBe('{…}');
});
it('omits defaults for properties without an entry', () => {
const props = findElement('ExtendingVideo')!.reference.hostProperties;
const props = findElement('ExtendingVideo')!.reference.platforms.html.properties.definitions;
expect(props.customDomain.default).toBeUndefined();
});
});
@@ -1521,16 +1603,16 @@ describe('Media element pipeline (end-to-end)', () => {
describe('Event extraction from capability contracts', () => {
it('video elements include text track events from VideoEvents', () => {
const ref = findElement('SimpleVideo')!.reference;
expect(ref.events.native).toContain('addtrack');
expect(ref.events.native).toContain('removetrack');
expect(ref.events.native).toContain('changetrack');
expect(ref.events.native).toContain('trackmodechange');
expect(ref.platforms.html.events.standard).toContain('addtrack');
expect(ref.platforms.html.events.standard).toContain('removetrack');
expect(ref.platforms.html.events.standard).toContain('changetrack');
expect(ref.platforms.html.events.standard).toContain('trackmodechange');
});
it('all video elements share the same native event list', () => {
const simple = findElement('SimpleVideo')!.reference.events.native;
const complex = findElement('ComplexVideo')!.reference.events.native;
const extending = findElement('ExtendingVideo')!.reference.events.native;
const simple = findElement('SimpleVideo')!.reference.platforms.html.events.standard;
const complex = findElement('ComplexVideo')!.reference.platforms.html.events.standard;
const extending = findElement('ExtendingVideo')!.reference.platforms.html.events.standard;
expect(complex).toEqual(simple);
expect(extending).toEqual(simple);
});
@@ -1561,7 +1643,7 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('walks function-declaration mixin (Shape A)', () => {
const props = findElement('MixinVideo')!.reference.hostProperties;
const props = findElement('MixinVideo')!.reference.platforms.html.properties.definitions;
expect(props.foo).toMatchObject({
type: 'string',
readonly: false,
@@ -1570,14 +1652,14 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('walks const-arrow mixin (Shape B)', () => {
const props = findElement('MixinVideo')!.reference.hostProperties;
const props = findElement('MixinVideo')!.reference.platforms.html.properties.definitions;
expect(props.volume).toBeDefined();
expect(props.volume.type).toBe('number');
expect(props.volume.readonly).toBe(false);
});
it('includes leaf-class own properties', () => {
const props = findElement('MixinVideo')!.reference.hostProperties;
const props = findElement('MixinVideo')!.reference.platforms.html.properties.definitions;
expect(props.bar).toMatchObject({
type: 'number',
readonly: false,
@@ -1586,12 +1668,12 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('marks volume as overridesNative (HTMLMediaElement member)', () => {
const props = findElement('MixinVideo')!.reference.hostProperties;
const props = findElement('MixinVideo')!.reference.platforms.html.properties.definitions;
expect(props.volume.overridesNative).toBe(true);
});
it('does not mark non-native properties as overridesNative', () => {
const props = findElement('MixinVideo')!.reference.hostProperties;
const props = findElement('MixinVideo')!.reference.platforms.html.properties.definitions;
expect(props.foo.overridesNative).toBeUndefined();
expect(props.bar.overridesNative).toBeUndefined();
});
@@ -1599,7 +1681,7 @@ describe('Media element pipeline (end-to-end)', () => {
it('inherits parent description when child override has no JSDoc', () => {
// src has JSDoc on MixinBaseHost; MixinB overrides without JSDoc.
// The description should fall through from the base.
const props = findElement('MixinVideo')!.reference.hostProperties;
const props = findElement('MixinVideo')!.reference.platforms.html.properties.definitions;
expect(props.src.description).toBe('Source URL of the media.');
});
@@ -1609,8 +1691,8 @@ describe('Media element pipeline (end-to-end)', () => {
// ONLY in the elementSpecific bucket (where they carry their description);
// they are excluded from native so they are never listed twice.
const ref = findElement('MixinVideo')!.reference;
expect(ref.events.native).not.toContain('streamtypechange');
expect(ref.events.elementSpecific).toContainEqual({
expect(ref.platforms.html.events.standard).not.toContain('streamtypechange');
expect(ref.platforms.html.events.custom).toContainEqual({
name: 'streamtypechange',
description: 'Fired when the detected stream type changes.',
});
@@ -1620,20 +1702,20 @@ describe('Media element pipeline (end-to-end)', () => {
// foochange is dispatched via this.dispatchEvent(new Event('foochange')) but
// has no @fires tag, so it is not surfaced — documentation requires a tag.
const ref = findElement('MixinVideo')!.reference;
const elementSpecificNames = ref.events.elementSpecific.map((e) => e.name);
const elementSpecificNames = ref.platforms.html.events.custom.map((e) => e.name);
expect(elementSpecificNames).not.toContain('foochange');
});
it('separates native events from element-specific events', () => {
const ref = findElement('MixinVideo')!.reference;
const elementSpecificNames = ref.events.elementSpecific.map((e) => e.name);
expect(ref.events.native).toContain('play');
expect(ref.events.native).not.toContain('foochange');
const elementSpecificNames = ref.platforms.html.events.custom.map((e) => e.name);
expect(ref.platforms.html.events.standard).toContain('play');
expect(ref.platforms.html.events.standard).not.toContain('foochange');
expect(elementSpecificNames).not.toContain('play');
});
it('extracts defaults declared in a mixin file', () => {
const props = findElement('MixinVideo')!.reference.hostProperties;
const props = findElement('MixinVideo')!.reference.platforms.html.properties.definitions;
expect(props.foo.default).toBe("''");
});
});
@@ -1662,7 +1744,7 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('resolves the mixin through another package barrel', () => {
const props = findElement('SpfAudio')!.reference.hostProperties;
const props = findElement('SpfAudio')!.reference.platforms.html.properties.definitions;
expect(props.src).toMatchObject({
type: 'string',
readonly: false,
@@ -1676,20 +1758,20 @@ describe('Media element pipeline (end-to-end)', () => {
});
it('extracts defaults declared next to the cross-package mixin', () => {
const props = findElement('SpfAudio')!.reference.hostProperties;
const props = findElement('SpfAudio')!.reference.platforms.html.properties.definitions;
expect(props.src.default).toBe("''");
expect(props.preload.default).toBe("''");
});
it('uses AudioEvents for native events (no text track events)', () => {
const ref = findElement('SpfAudio')!.reference;
expect(ref.events.native).toContain('play');
expect(ref.events.native).not.toContain('addtrack');
expect(ref.platforms.html.events.standard).toContain('play');
expect(ref.platforms.html.events.standard).not.toContain('addtrack');
});
it('surfaces a @fires event with its tag description', () => {
const ref = findElement('SpfAudio')!.reference;
expect(ref.events.elementSpecific).toContainEqual({
expect(ref.platforms.html.events.custom).toContainEqual({
name: 'audiomodechange',
description: 'Fired when the audio-only rendition changes.',
});
@@ -1697,7 +1779,7 @@ describe('Media element pipeline (end-to-end)', () => {
it('includes @fires-declared events without a scanned dispatch site', () => {
const ref = findElement('SpfAudio')!.reference;
expect(ref.events.elementSpecific).toContainEqual({
expect(ref.platforms.html.events.custom).toContainEqual({
name: 'manifestparsed',
description: 'Fired after the multivariant playlist is parsed.',
});
@@ -1705,21 +1787,31 @@ describe('Media element pipeline (end-to-end)', () => {
it('sorts element-specific events by name', () => {
const ref = findElement('SpfAudio')!.reference;
const names = ref.events.elementSpecific.map((e) => e.name);
const names = ref.platforms.html.events.custom.map((e) => e.name);
expect(names).toEqual([...names].sort());
});
it('has empty AudioCSSVars', () => {
const ref = findElement('SpfAudio')!.reference;
expect(ref.cssCustomProperties).toEqual({});
expect(ref.platforms.html.cssCustomProperties).toEqual({});
});
it('extracts audio methods from the shared base host (no video-only methods)', () => {
const ref = findElement('SpfAudio')!.reference;
// Audio methods = media-host methods + audio-host methods. The fixture
// audio host adds none, so video-only methods (requestFullscreen) are absent.
expect(ref.methods).toEqual(['canPlayType', 'load', 'pause', 'play']);
expect(ref.methods).not.toContain('requestFullscreen');
expect(ref.platforms.html.methods).toEqual(['canPlayType', 'load', 'pause', 'play']);
expect(ref.platforms.html.methods).not.toContain('requestFullscreen');
});
it('extracts native properties from the shared base host (no video-only props)', () => {
const ref = findElement('SpfAudio')!.reference;
// Audio native properties = media-host accessors only (audio host adds
// none), filtered to native members and deduped against hostProperties
// (src is re-declared by the mixin). videoWidth is video-only → absent.
expect(ref.platforms.html.properties.native).toEqual(['currentTime', 'volume']);
expect(ref.platforms.html.properties.native).not.toContain('videoWidth');
expect(ref.platforms.html.properties.native).not.toContain('src');
});
});
});
@@ -81,6 +81,7 @@ export function CustomMediaElement(tag: string, Host: any) {
poster: { type: String },
preload: { type: String },
src: { type: String },
streamType: { type: String, attribute: 'stream-type' },
};
}
@@ -0,0 +1,40 @@
/** Mock iframe-backed media host — mirrors VimeoMedia. */
export const embedMediaDefaultProps = {
src: '',
autoplay: false,
};
export class EmbedHost extends EventTarget {
#src = embedMediaDefaultProps.src;
#autoplay = embedMediaDefaultProps.autoplay;
get src(): string {
return this.#src;
}
set src(value: string) {
this.#src = value;
}
get autoplay(): boolean {
return this.#autoplay;
}
set autoplay(value: boolean) {
this.#autoplay = value;
}
/** Start playback through the embedded player. */
play(): Promise<void> {
this.dispatchEvent(new Event('play'));
return Promise.resolve();
}
attach(_target: HTMLIFrameElement): void {
const emit = (type: string) => this.dispatchEvent(new Event(type));
emit('waiting');
for (const type of ['loadedmetadata', 'adapterready']) {
this.dispatchEvent(new Event(type));
}
}
detach(): void {}
destroy(): void {}
}
@@ -4,6 +4,10 @@
* Exercises method extraction: the builder collects public instance methods
* from this class (per media type) for the reference's `methods` field.
* Lifecycle methods (attach/detach/destroy) and accessors are excluded.
*
* Also exercises native-property extraction: getters/setters whose names match
* native HTMLMediaElement members surface in `nativeProperties`; non-native
* accessors (e.g. `streamType`) and re-declared natives (`src`) do not.
*/
export class HTMLMediaElementHost {
// Lifecycle methods — excluded from `methods`.
@@ -14,11 +18,33 @@ export class HTMLMediaElementHost {
// Internal — excluded by the `_` prefix.
_forward(): void {}
// Accessor — excluded (getters/setters are properties, not methods).
// Accessor — excluded from `methods` (it's a property, not a method). Native
// member, but re-declared on engine hosts → deduped out of nativeProperties.
get src(): string {
return '';
}
// Native passthroughs — surface in nativeProperties.
get currentTime(): number {
return 0;
}
set currentTime(_value: number) {}
get volume(): number {
return 1;
}
set volume(_value: number) {}
// Video.js-specific — NOT a native member, so excluded from nativeProperties.
/**
* Current stream type (`'on-demand'`, `'live'`, or `'unknown'`). Consumers can
* set it when the host does not detect the stream type automatically.
*/
get streamType(): string {
return 'unknown';
}
set streamType(_value: string) {}
play(): Promise<void> {
return Promise.resolve();
}
@@ -2,7 +2,9 @@
* Mock video host base mirrors the real video-host.ts.
*
* Exercises video-specific method extraction: requestFullscreen is added on
* top of the shared media-host methods for video elements only.
* top of the shared media-host methods for video elements only. Also exercises
* video-only native-property extraction (videoWidth) and a non-native helper
* (isFullscreen) that must be filtered out of nativeProperties.
*/
import { HTMLMediaElementHost } from './media-host';
@@ -10,4 +12,14 @@ export class HTMLVideoElementHost extends HTMLMediaElementHost {
requestFullscreen(): Promise<void> {
return Promise.resolve();
}
// Native HTMLVideoElement member — surfaces in nativeProperties (video only).
get videoWidth(): number {
return 0;
}
// Video.js-specific helper — NOT a native member, excluded.
get isFullscreen(): boolean {
return false;
}
}
@@ -0,0 +1,5 @@
import { EmbedVideo } from '../../media/embed-video';
export class EmbedVideoElement extends EmbedVideo {
static readonly tagName = 'embed-video';
}
@@ -0,0 +1,10 @@
import { CustomMediaElement } from '../../../../core/src/dom/media/custom-media-element';
import { EmbedHost } from '../../../../core/src/dom/media/embed';
function MediaAttachMixin(base: any) {
return base;
}
class EmbedCustomMediaElement extends CustomMediaElement('iframe', EmbedHost) {}
export class EmbedVideo extends MediaAttachMixin(EmbedCustomMediaElement) {}
@@ -0,0 +1,22 @@
/**
* Mock React media component.
*
* Exercises the source conventions used by real media components:
* native React video props, a forwarded native-element ref, and a defaults
* object passed to useSyncProps for Video.js-specific props.
*/
import { complexMediaDefaultProps } from '../../../../core/src/dom/media/complex';
interface VideoHTMLAttributes<Element> {
element?: Element;
}
interface ComplexVideoProps extends VideoHTMLAttributes<HTMLVideoElement>, Partial<typeof complexMediaDefaultProps> {}
declare function forwardRef<Ref, Props>(render: (props: Props, ref: Ref) => unknown): unknown;
declare function useSyncProps(target: object, props: object, defaults: object): object;
export const ComplexVideo = forwardRef<HTMLVideoElement, ComplexVideoProps>(function ComplexVideo(props, ref) {
useSyncProps({}, props, complexMediaDefaultProps);
return { ref };
});
@@ -0,0 +1,11 @@
import { embedMediaDefaultProps } from '../../../../core/src/dom/media/embed';
interface EmbedVideoProps extends Partial<typeof embedMediaDefaultProps> {}
declare function forwardRef<Ref, Props>(render: (props: Props, ref: Ref) => unknown): unknown;
declare function useSyncProps(target: object, props: object, defaults: object): object;
export const EmbedVideo = forwardRef<HTMLIFrameElement, EmbedVideoProps>(function EmbedVideo(props, ref) {
useSyncProps({}, props, embedMediaDefaultProps);
return { ref };
});