mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
refactor(core): rework media config as a plain getter/setter (#1697)
This commit is contained in:
@@ -136,7 +136,7 @@ describe('HlsMedia', () => {
|
||||
media.addEventListener('streamtypechange', handler);
|
||||
|
||||
// New `hlsJs` option values must recreate the engine to take effect.
|
||||
media.config = { hlsJs: { maxBufferLength: 60 } };
|
||||
media.config = { ...media.config, hlsJs: { maxBufferLength: 60 } };
|
||||
media.load();
|
||||
|
||||
// Teardown `live` → `unknown`, then the new delegate re-detects `live`.
|
||||
@@ -147,7 +147,7 @@ describe('HlsMedia', () => {
|
||||
it('does not recreate the engine for an equivalent hlsJs config', () => {
|
||||
const { media, video } = setup();
|
||||
|
||||
media.config = { hlsJs: { maxBufferLength: 60 } };
|
||||
media.config = { ...media.config, hlsJs: { maxBufferLength: 60 } };
|
||||
media.load();
|
||||
|
||||
fireDurationChange(video, Infinity);
|
||||
@@ -155,20 +155,22 @@ describe('HlsMedia', () => {
|
||||
media.addEventListener('streamtypechange', handler);
|
||||
|
||||
// Same option values in a new object (e.g. an inline React prop).
|
||||
media.config = { hlsJs: { maxBufferLength: 60 } };
|
||||
media.config = { ...media.config, hlsJs: { maxBufferLength: 60 } };
|
||||
media.load();
|
||||
|
||||
// No engine teardown → no streamType churn.
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merges config assignments', () => {
|
||||
it('resets free-form config when a new object is assigned', () => {
|
||||
const { media } = setup();
|
||||
|
||||
media.config = { hlsJs: { maxBufferLength: 60 } };
|
||||
|
||||
// Keys from the previous assignment in `setup()` survive.
|
||||
expect(media.config.preferPlayback).toBe('native');
|
||||
// A new config object signals a fresh start: prior free-form keys
|
||||
// (set in `setup()`) are dropped rather than merged.
|
||||
expect(media.config.preferPlayback).toBeUndefined();
|
||||
expect(media.config.contentType).toBeUndefined();
|
||||
expect(media.config.hlsJs).toEqual({ maxBufferLength: 60 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type TextTrackLike,
|
||||
} from '../../core/media/types';
|
||||
import { EMPTY_REMOTE, EMPTY_TEXT_TRACKS, EMPTY_TIME_RANGES } from './constants';
|
||||
import { getComponents, getProp, setProp } from './utils';
|
||||
import { getComponents, getOwner, getProp, setProp } from './utils';
|
||||
|
||||
export { addComponent, getComponents, getOwner, getProp, setProp } from './utils';
|
||||
|
||||
@@ -89,8 +89,6 @@ export class HTMLMediaElementHost<Target extends HTMLMediaTargetLike, Events ext
|
||||
const components = getComponents(this);
|
||||
for (const component of components.values()) {
|
||||
component.destroy?.();
|
||||
const { configKey } = component.constructor as ComponentConstructor;
|
||||
if (configKey) delete this.#config[configKey];
|
||||
}
|
||||
components.clear();
|
||||
}
|
||||
@@ -149,7 +147,13 @@ export class HTMLMediaElementHost<Target extends HTMLMediaTargetLike, Events ext
|
||||
return this.#config;
|
||||
}
|
||||
set config(value: MediaConfig) {
|
||||
Object.assign(this.#config, value);
|
||||
this.#config = value;
|
||||
|
||||
for (const component of getComponents(this).values()) {
|
||||
const ctor = component.constructor as ComponentConstructor;
|
||||
const componentConfig = ctor.configKey && value[ctor.configKey];
|
||||
if (componentConfig) Object.assign(component, componentConfig);
|
||||
}
|
||||
}
|
||||
|
||||
get title() {
|
||||
@@ -182,12 +186,13 @@ export class HTMLMediaElementHost<Target extends HTMLMediaTargetLike, Events ext
|
||||
}
|
||||
|
||||
play() {
|
||||
const play = getProp(this, 'play');
|
||||
return play?.() ?? Promise.reject(new DOMException('No media is attached.', 'NotSupportedError'));
|
||||
const owner = getOwner(this, 'play');
|
||||
return owner?.play?.() ?? Promise.reject(new DOMException('No media is attached.', 'NotSupportedError'));
|
||||
}
|
||||
|
||||
pause() {
|
||||
getProp(this, 'pause')?.();
|
||||
const owner = getOwner(this, 'pause');
|
||||
owner?.pause?.();
|
||||
}
|
||||
|
||||
get autoplay() {
|
||||
@@ -242,11 +247,13 @@ export class HTMLMediaElementHost<Target extends HTMLMediaTargetLike, Events ext
|
||||
}
|
||||
|
||||
load() {
|
||||
return getProp(this, 'load')?.();
|
||||
const owner = getOwner(this, 'load');
|
||||
return owner?.load?.();
|
||||
}
|
||||
|
||||
canPlayType(type: string) {
|
||||
return getProp(this, 'canPlayType')?.(type) ?? '';
|
||||
const owner = getOwner(this, 'canPlayType');
|
||||
return owner?.canPlayType?.(type) ?? '';
|
||||
}
|
||||
|
||||
get volume() {
|
||||
@@ -305,7 +312,8 @@ export class HTMLMediaElementHost<Target extends HTMLMediaTargetLike, Events ext
|
||||
}
|
||||
|
||||
addTextTrack(kind: TextTrackKind, label?: string, language?: string) {
|
||||
return getProp(this, 'addTextTrack')?.(kind, label, language) as TextTrackLike;
|
||||
const owner = getOwner(this, 'addTextTrack');
|
||||
return owner?.addTextTrack?.(kind, label, language) as TextTrackLike;
|
||||
}
|
||||
|
||||
get remote() {
|
||||
|
||||
@@ -94,17 +94,22 @@ describe('MuxData', () => {
|
||||
|
||||
it('exposes mux config under host.config.muxData with inferred types', () => {
|
||||
const media = new HlsMedia();
|
||||
addComponent(media, new MuxData());
|
||||
const muxData = new MuxData();
|
||||
addComponent(media, muxData);
|
||||
|
||||
// Type-level: `config.muxData` infers `Partial<MuxDataProps>` via the
|
||||
// component's `configKey` augmentation, so these assignments/reads are
|
||||
// checked. This line fails to compile if inference regresses.
|
||||
media.config.muxData = { envKey: 'key', debug: true };
|
||||
// component's `configKey` augmentation, so the assignment/read are checked.
|
||||
// This fails to compile if inference regresses.
|
||||
media.config = { muxData: { envKey: 'key', debug: true } };
|
||||
const envKey: string | undefined = media.config.muxData?.envKey;
|
||||
|
||||
expect(envKey).toBe('key');
|
||||
// Live binding: the write reached the component instance.
|
||||
expect(media.config.muxData).toBeInstanceOf(MuxData);
|
||||
// `config` stores the plain namespace POJO, not the component instance.
|
||||
expect(media.config.muxData).toEqual({ envKey: 'key', debug: true });
|
||||
expect(media.config.muxData).not.toBeInstanceOf(MuxData);
|
||||
// The setter still routed those values onto the live component instance.
|
||||
expect(muxData.envKey).toBe('key');
|
||||
expect(muxData.debug).toBe(true);
|
||||
});
|
||||
|
||||
it('stops re-monitoring after destroy', async () => {
|
||||
|
||||
@@ -191,85 +191,111 @@ describe('HTMLMediaElementHost', () => {
|
||||
});
|
||||
|
||||
describe('component config binding', () => {
|
||||
it('exposes the component instance under its configKey', () => {
|
||||
it('applies a component namespace onto the component when config is set', () => {
|
||||
const host = new HTMLAudioElementHost();
|
||||
const component = new ConfigurableComponent();
|
||||
addComponent(host, component);
|
||||
|
||||
expect(host.config.fake).toBe(component);
|
||||
});
|
||||
|
||||
it('reads live values from the component', () => {
|
||||
const host = new HTMLAudioElementHost();
|
||||
const component = new ConfigurableComponent();
|
||||
addComponent(host, component);
|
||||
|
||||
component.value = 5;
|
||||
expect((host.config.fake as ConfigurableComponent).value).toBe(5);
|
||||
});
|
||||
|
||||
it('assigns onto the component when the namespace is written', () => {
|
||||
const host = new HTMLAudioElementHost();
|
||||
const component = new ConfigurableComponent();
|
||||
addComponent(host, component);
|
||||
|
||||
host.config.fake = { value: 7, label: 'hi' };
|
||||
|
||||
expect(component.value).toBe(7);
|
||||
expect(component.label).toBe('hi');
|
||||
});
|
||||
|
||||
it('routes component keys through the config setter', () => {
|
||||
const host = new HTMLAudioElementHost();
|
||||
const component = new ConfigurableComponent();
|
||||
addComponent(host, component);
|
||||
|
||||
host.config = { fake: { value: 3 }, hlsJs: { debug: true } };
|
||||
host.config = { fake: { value: 3, label: 'a' } };
|
||||
|
||||
expect(component.value).toBe(3);
|
||||
// Component instance is not stored as a plain object on the host.
|
||||
expect(host.config.fake).toBe(component);
|
||||
// Non-component keys are stored on the host bag.
|
||||
expect(host.config.hlsJs).toEqual({ debug: true });
|
||||
expect(component.label).toBe('a');
|
||||
});
|
||||
|
||||
it('reflects a component added after the first config access', () => {
|
||||
const host = new HTMLAudioElementHost();
|
||||
// Access config before the component exists to build the proxy.
|
||||
expect(host.config.fake).toBeUndefined();
|
||||
|
||||
const component = new ConfigurableComponent();
|
||||
addComponent(host, component);
|
||||
|
||||
expect(host.config.fake).toBe(component);
|
||||
});
|
||||
|
||||
it('includes the configKey in has/ownKeys', () => {
|
||||
it('stores config as plain values, never component instances', () => {
|
||||
const host = new HTMLAudioElementHost();
|
||||
addComponent(host, new ConfigurableComponent());
|
||||
|
||||
expect('fake' in host.config).toBe(true);
|
||||
expect(Object.keys(host.config)).toContain('fake');
|
||||
host.config = { fake: { value: 3 }, hlsJs: { debug: true } };
|
||||
|
||||
// `config` is a plain bag of what was set — reading it back yields the
|
||||
// assigned POJO, never the component instance.
|
||||
expect(host.config.fake).toEqual({ value: 3 });
|
||||
expect(host.config.fake).not.toBeInstanceOf(ConfigurableComponent);
|
||||
expect(host.config.hlsJs).toEqual({ debug: true });
|
||||
});
|
||||
|
||||
it('merges host-level keys on assignment', () => {
|
||||
it('returns the same object that was assigned', () => {
|
||||
const host = new HTMLAudioElementHost();
|
||||
const value = { fake: { value: 1 }, a: 2 };
|
||||
|
||||
host.config = value;
|
||||
|
||||
expect(host.config).toBe(value);
|
||||
});
|
||||
|
||||
it('round-trips through JSON without leaking component instances', () => {
|
||||
const host = new HTMLAudioElementHost();
|
||||
addComponent(host, new ConfigurableComponent());
|
||||
|
||||
host.config = { fake: { value: 5, label: 'a' }, a: 1 };
|
||||
|
||||
// The stringified getter is valid input to the setter — plain values only.
|
||||
const serialized = JSON.parse(JSON.stringify(host.config));
|
||||
expect(serialized).toEqual({ fake: { value: 5, label: 'a' }, a: 1 });
|
||||
});
|
||||
|
||||
it('does not apply config when the returned object is mutated directly', () => {
|
||||
const host = new HTMLAudioElementHost();
|
||||
const component = new ConfigurableComponent();
|
||||
addComponent(host, component);
|
||||
|
||||
// Only the setter applies namespaces to components; mutating the bag in
|
||||
// place bypasses it.
|
||||
host.config.fake = { value: 7, label: 'hi' };
|
||||
|
||||
expect(component.value).toBe(0);
|
||||
expect(component.label).toBe('');
|
||||
});
|
||||
|
||||
it('replaces the entire config object on set', () => {
|
||||
const host = new HTMLAudioElementHost();
|
||||
|
||||
host.config = { a: 1 };
|
||||
host.config = { b: 2 };
|
||||
|
||||
expect(host.config.a).toBe(1);
|
||||
// A new object replaces the old one wholesale; prior keys are dropped.
|
||||
expect(host.config.a).toBeUndefined();
|
||||
expect(host.config.b).toBe(2);
|
||||
});
|
||||
|
||||
it('removes the config binding when the component is removed', () => {
|
||||
it('keeps component state when a later config omits its namespace', () => {
|
||||
const host = new HTMLAudioElementHost();
|
||||
const remove = addComponent(host, new ConfigurableComponent());
|
||||
const component = new ConfigurableComponent();
|
||||
addComponent(host, component);
|
||||
|
||||
host.config = { fake: { value: 5 }, a: 1 };
|
||||
host.config = { b: 2 };
|
||||
|
||||
// The component retains its applied state even though the new config
|
||||
// object no longer lists its namespace.
|
||||
expect(component.value).toBe(5);
|
||||
expect(host.config.fake).toBeUndefined();
|
||||
expect(host.config.a).toBeUndefined();
|
||||
expect(host.config.b).toBe(2);
|
||||
});
|
||||
|
||||
it('overwrites component state only for keys present in the new config', () => {
|
||||
const host = new HTMLAudioElementHost();
|
||||
const component = new ConfigurableComponent();
|
||||
addComponent(host, component);
|
||||
|
||||
host.config = { fake: { value: 5, label: 'a' } };
|
||||
host.config = { fake: { value: 9 } };
|
||||
|
||||
expect(component.value).toBe(9);
|
||||
expect(component.label).toBe('a');
|
||||
});
|
||||
|
||||
it('stops applying config to a removed component', () => {
|
||||
const host = new HTMLAudioElementHost();
|
||||
const component = new ConfigurableComponent();
|
||||
const remove = addComponent(host, component);
|
||||
|
||||
remove();
|
||||
host.config = { fake: { value: 7 } };
|
||||
|
||||
expect(host.config.fake).toBeUndefined();
|
||||
expect('fake' in host.config).toBe(false);
|
||||
expect(component.value).toBe(0);
|
||||
});
|
||||
|
||||
it('adopts config set before the component was registered', () => {
|
||||
@@ -281,7 +307,23 @@ describe('HTMLMediaElementHost', () => {
|
||||
|
||||
expect(component.value).toBe(4);
|
||||
expect(component.label).toBe('early');
|
||||
expect(host.config.fake).toBe(component);
|
||||
// The plain value stays in the bag; it was never replaced.
|
||||
expect(host.config.fake).toEqual({ value: 4, label: 'early' });
|
||||
});
|
||||
|
||||
it('drops pre-registration component config after an intervening config reset', () => {
|
||||
const host = new HTMLAudioElementHost();
|
||||
host.config = { fake: { value: 4, label: 'early' } };
|
||||
// A later config object replaces the bag wholesale, so the staged value is
|
||||
// gone before the component registers.
|
||||
host.config = { a: 1 };
|
||||
|
||||
const component = new ConfigurableComponent();
|
||||
addComponent(host, component);
|
||||
|
||||
expect(component.value).toBe(0);
|
||||
expect(component.label).toBe('');
|
||||
expect(host.config.fake).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { HTMLAudioElementHost } from '../audio-host';
|
||||
import { getProp } from '../utils';
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('getProp', () => {
|
||||
it('returns the owner value', () => {
|
||||
const host = new HTMLAudioElementHost();
|
||||
const audio = document.createElement('audio');
|
||||
audio.loop = true;
|
||||
host.attach(audio);
|
||||
|
||||
expect(getProp(host, 'loop')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns undefined when nothing is attached', () => {
|
||||
const host = new HTMLAudioElementHost();
|
||||
expect(getProp(host, 'loop')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+20
-28
@@ -1,56 +1,48 @@
|
||||
import { isFunction } from '@videojs/utils/predicate';
|
||||
import type {
|
||||
Component,
|
||||
ComponentConstructor,
|
||||
Components,
|
||||
HTMLMediaElementHost,
|
||||
HTMLMediaTargetLike as TargetLike,
|
||||
} from './media-host';
|
||||
} from '../media-host';
|
||||
|
||||
type Host<T extends TargetLike = any> = HTMLMediaElementHost<T, any>;
|
||||
export type Host<T extends TargetLike = any> = HTMLMediaElementHost<T, any>;
|
||||
|
||||
const registry = new WeakMap<Host, Components>();
|
||||
const componentRegistry = new WeakMap<Host, Components>();
|
||||
|
||||
export function getComponents(host: Host) {
|
||||
let map = registry.get(host);
|
||||
if (!map) registry.set(host, (map = new Map() as Components));
|
||||
let map = componentRegistry.get(host);
|
||||
if (!map) componentRegistry.set(host, (map = new Map() as Components));
|
||||
return map;
|
||||
}
|
||||
|
||||
export function addComponent<T extends Component>(host: Host, instance: T) {
|
||||
export function addComponent<T extends Component>(host: Host, component: T) {
|
||||
const components = getComponents(host);
|
||||
const ctor = instance.constructor as ComponentConstructor<T>;
|
||||
components.set(ctor, instance);
|
||||
// Get the component's constructor to use as the key for the component in the registry.
|
||||
const ctor = component.constructor as ComponentConstructor<T>;
|
||||
|
||||
// Expose a live binding on `host.config`: reads return the component, writes assign onto it.
|
||||
// Adopt any config set under this namespace before the component registered.
|
||||
const { configKey } = ctor;
|
||||
if (configKey) {
|
||||
// Adopt config set before the component was registered.
|
||||
const initial = host.config[configKey];
|
||||
Object.defineProperty(host.config, configKey, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => instance,
|
||||
set: (value) => Object.assign(instance, value),
|
||||
});
|
||||
if (initial) Object.assign(instance, initial);
|
||||
}
|
||||
const staged = configKey ? host.config[configKey] : undefined;
|
||||
|
||||
components.set(ctor, component);
|
||||
|
||||
if (staged !== undefined) Object.assign(component, staged);
|
||||
|
||||
component.setMedia?.(host);
|
||||
|
||||
instance.setMedia?.(host);
|
||||
// @ts-expect-error `target` is protected, but these helpers are the host's own machinery.
|
||||
if (host.target) instance.attach?.(host.target);
|
||||
if (host.target) component.attach?.(host.target);
|
||||
|
||||
return () => {
|
||||
if (components.get(ctor) === instance) {
|
||||
if (components.get(ctor) === component) {
|
||||
components.delete(ctor);
|
||||
if (configKey) delete host.config[configKey];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getProp<T extends TargetLike, K extends keyof T>(host: Host<T>, prop: K): T[K] | undefined {
|
||||
const own = getOwner(host, prop);
|
||||
const result = own?.[prop];
|
||||
return isFunction(result) ? (result.bind(own) as T[K]) : result;
|
||||
return getOwner(host, prop)?.[prop];
|
||||
}
|
||||
|
||||
export function setProp<T extends TargetLike, K extends keyof T>(host: Host<T>, prop: K, value: T[K]): void {
|
||||
@@ -0,0 +1 @@
|
||||
export { addComponent, getComponents, getOwner, getProp, setProp } from './components';
|
||||
Reference in New Issue
Block a user