mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages)!: support media components as markup (#1883)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
import '../../define/media/google-cast';
|
||||
@@ -0,0 +1 @@
|
||||
import '../../define/media/mux-data';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { GoogleCastElement } from '../../media/google-cast';
|
||||
import { safeDefine } from '../safe-define';
|
||||
|
||||
export { GoogleCastElement };
|
||||
|
||||
safeDefine(GoogleCastElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[GoogleCastElement.tagName]: GoogleCastElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MuxDataElement } from '../../media/mux-data';
|
||||
import { safeDefine } from '../safe-define';
|
||||
|
||||
export { MuxDataElement };
|
||||
|
||||
safeDefine(MuxDataElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[MuxDataElement.tagName]: MuxDataElement;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
import { CustomMediaElement } from '@videojs/media/dom/custom-media-element';
|
||||
import { DashMedia } from '@videojs/media/dom/dash';
|
||||
import { GoogleCast } from '@videojs/media/dom/google-cast';
|
||||
import { addMediaComponent } from '@videojs/media/dom/media-host';
|
||||
import { MediaAttachMixin } from '../../store/media-attach-mixin';
|
||||
|
||||
export class DashVideo extends MediaAttachMixin(CustomMediaElement('video', DashMedia)) {
|
||||
constructor() {
|
||||
super();
|
||||
addMediaComponent(this.host, new GoogleCast());
|
||||
}
|
||||
}
|
||||
export class DashVideo extends MediaAttachMixin(CustomMediaElement('video', DashMedia)) {}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { PropertyDeclarationMap } from '@videojs/element';
|
||||
import { GoogleCast, type GoogleCastProps } from '@videojs/media/dom/google-cast';
|
||||
|
||||
import { MediaComponentElement } from '../media-component-element';
|
||||
|
||||
/**
|
||||
* Adds Google Cast support to the surrounding player's media.
|
||||
*
|
||||
* Renders nothing — place it inside the player as a sibling of the media
|
||||
* element and it registers a {@link GoogleCast} media component with the
|
||||
* active media host.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <video-player>
|
||||
* <hlsjs-video src="https://example.com/stream.m3u8"></hlsjs-video>
|
||||
* <google-cast receiver="YOUR_APP_ID"></google-cast>
|
||||
* </video-player>
|
||||
* ```
|
||||
*/
|
||||
export class GoogleCastElement extends MediaComponentElement<GoogleCast> {
|
||||
static readonly tagName = 'google-cast';
|
||||
|
||||
static override properties = {
|
||||
src: { type: String },
|
||||
contentType: { type: String, attribute: 'content-type' },
|
||||
streamType: { type: String, attribute: 'stream-type' },
|
||||
receiver: { type: String },
|
||||
// `customData` takes an object, so it's a property-only prop.
|
||||
} satisfies PropertyDeclarationMap<Exclude<keyof GoogleCastProps, 'customData'>>;
|
||||
|
||||
protected createComponent(): GoogleCast {
|
||||
return new GoogleCast();
|
||||
}
|
||||
|
||||
/** Source URL loaded on the Cast receiver. Falls back to the media's `src` / `currentSrc`. */
|
||||
get src(): string {
|
||||
return this.component.src ?? '';
|
||||
}
|
||||
|
||||
set src(value: string | null | undefined) {
|
||||
this.component.src = value ?? undefined;
|
||||
}
|
||||
|
||||
/** MIME type of the Cast source. When unset, the receiver infers it from the URL. */
|
||||
get contentType(): string | undefined {
|
||||
return this.component.contentType;
|
||||
}
|
||||
|
||||
set contentType(value: string | null | undefined) {
|
||||
this.component.contentType = value ?? undefined;
|
||||
}
|
||||
|
||||
/** Stream type used on the Cast receiver. Falls back to the media's `streamType`. */
|
||||
get streamType(): GoogleCastProps['streamType'] {
|
||||
return this.component.streamType;
|
||||
}
|
||||
|
||||
set streamType(value: GoogleCastProps['streamType'] | null) {
|
||||
this.component.streamType = value ?? undefined;
|
||||
}
|
||||
|
||||
/** Cast receiver application ID. Defaults to Google's default media receiver. */
|
||||
get receiver(): string | undefined {
|
||||
return this.component.receiver;
|
||||
}
|
||||
|
||||
set receiver(value: string | null | undefined) {
|
||||
this.component.receiver = value ?? undefined;
|
||||
}
|
||||
|
||||
/** Custom data sent to the Cast receiver with the load request. */
|
||||
get customData(): GoogleCastProps['customData'] {
|
||||
return this.component.customData;
|
||||
}
|
||||
|
||||
set customData(value: GoogleCastProps['customData']) {
|
||||
this.component.customData = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './google-cast-element';
|
||||
@@ -1,12 +1,5 @@
|
||||
import { CustomMediaElement } from '@videojs/media/dom/custom-media-element';
|
||||
import { GoogleCast } from '@videojs/media/dom/google-cast';
|
||||
import { HlsJsMedia } from '@videojs/media/dom/hls-js';
|
||||
import { addMediaComponent } from '@videojs/media/dom/media-host';
|
||||
import { MediaAttachMixin } from '../../store/media-attach-mixin';
|
||||
|
||||
export class HlsJsVideo extends MediaAttachMixin(CustomMediaElement('video', HlsJsMedia)) {
|
||||
constructor() {
|
||||
super();
|
||||
addMediaComponent(this.host, new GoogleCast());
|
||||
}
|
||||
}
|
||||
export class HlsJsVideo extends MediaAttachMixin(CustomMediaElement('video', HlsJsMedia)) {}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ContextConsumer } from '@videojs/element/context';
|
||||
import type { Media } from '@videojs/media/dom';
|
||||
import {
|
||||
addMediaComponent,
|
||||
HTMLMediaElementHost,
|
||||
type HTMLMediaTargetLike,
|
||||
type MediaComponent,
|
||||
} from '@videojs/media/dom/media-host';
|
||||
|
||||
import { mediaContext } from '../player/context';
|
||||
import { MediaElement } from '../ui/media-element';
|
||||
|
||||
type MediaHost = HTMLMediaElementHost<HTMLMediaTargetLike, any>;
|
||||
|
||||
/** Resolve the media host from a context media value (a media custom element or the host itself). */
|
||||
function resolveMediaHost(media: Media | null): MediaHost | null {
|
||||
if (media instanceof HTMLMediaElementHost) return media;
|
||||
const host = (media as { host?: unknown } | null)?.host;
|
||||
return host instanceof HTMLMediaElementHost ? host : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base for elements that register a media component (e.g. Mux Data,
|
||||
* Google Cast) with the media provided by the surrounding player.
|
||||
*
|
||||
* Place inside a player, as a sibling of the media element. The component is
|
||||
* registered when a media host becomes available, follows the media when it
|
||||
* changes, is removed when this element disconnects, and is destroyed with
|
||||
* this element.
|
||||
*/
|
||||
export abstract class MediaComponentElement<Component extends MediaComponent> extends MediaElement {
|
||||
#component: Component | null = null;
|
||||
#host: MediaHost | null = null;
|
||||
#removeComponent: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Create the media component this element registers. Called once, lazily.
|
||||
*
|
||||
* Must be a method rather than a field: upgrading an element that is already
|
||||
* in the document runs its constructor while connected, so the media context
|
||||
* callback below can fire before subclass field initializers have run.
|
||||
*/
|
||||
protected abstract createComponent(): Component;
|
||||
|
||||
/** The media component instance registered with the media host. */
|
||||
protected get component(): Component {
|
||||
return (this.#component ??= this.createComponent());
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Registers itself as a controller on this host; re-requests on reconnect.
|
||||
new ContextConsumer(this, {
|
||||
context: mediaContext,
|
||||
subscribe: true,
|
||||
callback: (value) => this.#setHost(resolveMediaHost(value.media)),
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
// Remove the component while the media chain is still live so it can
|
||||
// clean up against the real underlying target.
|
||||
this.#setHost(null);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override destroyCallback(): void {
|
||||
this.#setHost(null);
|
||||
// Don't create a component just to destroy it.
|
||||
this.#component?.destroy?.();
|
||||
super.destroyCallback();
|
||||
}
|
||||
|
||||
#setHost(host: MediaHost | null): void {
|
||||
if (this.#host === host) return;
|
||||
|
||||
this.#removeComponent?.();
|
||||
this.#removeComponent = null;
|
||||
this.#host = host;
|
||||
|
||||
if (host) this.#removeComponent = addMediaComponent(host, this.component);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { CustomMediaElement } from '@videojs/media/dom/custom-media-element';
|
||||
import { GoogleCast } from '@videojs/media/dom/google-cast';
|
||||
import { addMediaComponent } from '@videojs/media/dom/media-host';
|
||||
import { MuxData, MuxMedia } from '@videojs/media/dom/mux';
|
||||
import { MuxMedia } from '@videojs/media/dom/mux';
|
||||
import { MediaAttachMixin } from '../../store/media-attach-mixin';
|
||||
|
||||
const MuxAudioBase = MediaAttachMixin(CustomMediaElement('audio', MuxMedia));
|
||||
@@ -9,8 +7,6 @@ const MuxAudioBase = MediaAttachMixin(CustomMediaElement('audio', MuxMedia));
|
||||
export class MuxAudio extends MuxAudioBase {
|
||||
constructor() {
|
||||
super();
|
||||
addMediaComponent(this.host, new MuxData({ playerSoftwareName: 'mux-audio' }));
|
||||
addMediaComponent(this.host, new GoogleCast());
|
||||
this.host.addEventListener('sourcechange', () => this.#reflectSrc());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './mux-data-element';
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { PropertyDeclarationMap } from '@videojs/element';
|
||||
import { MuxData, type MuxDataProps } from '@videojs/media/dom/mux';
|
||||
|
||||
import { MediaComponentElement } from '../media-component-element';
|
||||
|
||||
/**
|
||||
* Adds [Mux Data](https://www.mux.com/data) monitoring to the surrounding
|
||||
* player's media.
|
||||
*
|
||||
* Renders nothing — place it inside the player as a sibling of the media
|
||||
* element and it registers a {@link MuxData} media component with the active
|
||||
* media host.
|
||||
*
|
||||
* Mux-hosted playback needs no `env-key`: the view reports the Mux playback ID
|
||||
* as its `video_id`, which Mux attributes to the owning environment. Set
|
||||
* `env-key` to monitor sources Mux doesn't host.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <video-player>
|
||||
* <mux-video src="https://stream.mux.com/abc123.m3u8"></mux-video>
|
||||
* <mux-data player-software-name="mux-video"></mux-data>
|
||||
* </video-player>
|
||||
* ```
|
||||
*/
|
||||
export class MuxDataElement extends MediaComponentElement<MuxData> {
|
||||
static readonly tagName = 'mux-data';
|
||||
|
||||
static override properties = {
|
||||
envKey: { type: String, attribute: 'env-key' },
|
||||
beaconCollectionDomain: { type: String, attribute: 'beacon-collection-domain' },
|
||||
debug: { type: Boolean },
|
||||
disableCookies: { type: Boolean, attribute: 'disable-cookies' },
|
||||
playerSoftwareName: { type: String, attribute: 'player-software-name' },
|
||||
playerSoftwareVersion: { type: String, attribute: 'player-software-version' },
|
||||
playerInitTime: { type: Number, attribute: 'player-init-time' },
|
||||
// `metadata` and `MuxDataSdk` take objects, so they're property-only props.
|
||||
} satisfies PropertyDeclarationMap<Exclude<keyof MuxDataProps, 'metadata' | 'MuxDataSdk'>>;
|
||||
|
||||
protected createComponent(): MuxData {
|
||||
return new MuxData();
|
||||
}
|
||||
|
||||
/** Mux Data environment key for the beacons. Optional for Mux-hosted playback. */
|
||||
get envKey(): string | undefined {
|
||||
return this.component.envKey;
|
||||
}
|
||||
|
||||
set envKey(value: string | null | undefined) {
|
||||
this.component.envKey = value ?? undefined;
|
||||
}
|
||||
|
||||
/** Custom domain beacons are sent to. */
|
||||
get beaconCollectionDomain(): string | undefined {
|
||||
return this.component.beaconCollectionDomain;
|
||||
}
|
||||
|
||||
set beaconCollectionDomain(value: string | null | undefined) {
|
||||
this.component.beaconCollectionDomain = value ?? undefined;
|
||||
}
|
||||
|
||||
/** Enables Mux Data SDK debug logging. */
|
||||
get debug(): boolean {
|
||||
return this.component.debug;
|
||||
}
|
||||
|
||||
set debug(value: boolean) {
|
||||
this.component.debug = value;
|
||||
}
|
||||
|
||||
/** Disables Mux Data SDK cookies. */
|
||||
get disableCookies(): boolean {
|
||||
return this.component.disableCookies;
|
||||
}
|
||||
|
||||
set disableCookies(value: boolean) {
|
||||
this.component.disableCookies = value;
|
||||
}
|
||||
|
||||
/** Player software name reported to Mux Data (e.g. `mux-video`). */
|
||||
get playerSoftwareName(): string | undefined {
|
||||
return this.component.playerSoftwareName;
|
||||
}
|
||||
|
||||
set playerSoftwareName(value: string | null | undefined) {
|
||||
this.component.playerSoftwareName = value ?? undefined;
|
||||
}
|
||||
|
||||
/** Player software version reported to Mux Data. Defaults to the Video.js version. */
|
||||
get playerSoftwareVersion(): string | undefined {
|
||||
return this.component.playerSoftwareVersion;
|
||||
}
|
||||
|
||||
set playerSoftwareVersion(value: string | null | undefined) {
|
||||
this.component.playerSoftwareVersion = value ?? undefined;
|
||||
}
|
||||
|
||||
/** Epoch milliseconds the player was initialized. Defaults to the component's creation time. */
|
||||
get playerInitTime(): number | undefined {
|
||||
return this.component.playerInitTime;
|
||||
}
|
||||
|
||||
set playerInitTime(value: number | null | undefined) {
|
||||
this.component.playerInitTime = value ?? undefined;
|
||||
}
|
||||
|
||||
/** Custom view metadata forwarded to the Mux Data SDK. */
|
||||
get metadata(): MuxDataProps['metadata'] {
|
||||
return this.component.metadata;
|
||||
}
|
||||
|
||||
set metadata(value: MuxDataProps['metadata']) {
|
||||
this.component.metadata = value;
|
||||
}
|
||||
|
||||
/** Mux Data SDK used for monitoring. Set to `undefined` to disable monitoring. */
|
||||
get MuxDataSdk(): MuxDataProps['MuxDataSdk'] {
|
||||
return this.component.MuxDataSdk;
|
||||
}
|
||||
|
||||
set MuxDataSdk(value: MuxDataProps['MuxDataSdk']) {
|
||||
this.component.MuxDataSdk = value;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { CustomMediaElement } from '@videojs/media/dom/custom-media-element';
|
||||
import { GoogleCast } from '@videojs/media/dom/google-cast';
|
||||
import { StreamTypes } from '@videojs/media/dom/hls-js';
|
||||
import { addMediaComponent } from '@videojs/media/dom/media-host';
|
||||
import { MuxData, MuxMedia } from '@videojs/media/dom/mux';
|
||||
import { MuxMedia } from '@videojs/media/dom/mux';
|
||||
import { MediaAttachMixin } from '../../store/media-attach-mixin';
|
||||
|
||||
const MuxVideoBase = MediaAttachMixin(CustomMediaElement('video', MuxMedia));
|
||||
@@ -16,8 +14,6 @@ export class MuxVideo extends MuxVideoBase {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
addMediaComponent(this.host, new MuxData({ playerSoftwareName: 'mux-video' }));
|
||||
addMediaComponent(this.host, new GoogleCast());
|
||||
// Storyboards aren't generated for live streams; re-evaluate when the type is detected.
|
||||
this.host.addEventListener('streamtypechange', () => this.#syncStoryboard());
|
||||
// Covers both the `src` attribute and the `source` property (JS-only).
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { CustomMediaElement } from '@videojs/media/dom/custom-media-element';
|
||||
import { GoogleCast } from '@videojs/media/dom/google-cast';
|
||||
import { addMediaComponent } from '@videojs/media/dom/media-host';
|
||||
import { NativeHlsMedia } from '@videojs/media/dom/native-hls';
|
||||
import { MediaAttachMixin } from '../../store/media-attach-mixin';
|
||||
|
||||
export class NativeHlsVideo extends MediaAttachMixin(CustomMediaElement('video', NativeHlsMedia)) {
|
||||
constructor() {
|
||||
super();
|
||||
addMediaComponent(this.host, new GoogleCast());
|
||||
}
|
||||
}
|
||||
export class NativeHlsVideo extends MediaAttachMixin(CustomMediaElement('video', NativeHlsMedia)) {}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
import type { Media } from '@videojs/media/dom';
|
||||
import { GoogleCast } from '@videojs/media/dom/google-cast';
|
||||
import { getMediaComponents } from '@videojs/media/dom/media-host';
|
||||
import { HTMLVideoElementHost } from '@videojs/media/dom/video-host';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mediaContext } from '../../player/context';
|
||||
import { MediaElement } from '../../ui/media-element';
|
||||
import { GoogleCastElement } from '../google-cast';
|
||||
|
||||
class TestMediaProvider extends MediaElement {
|
||||
readonly #provider = new ContextProvider(this, {
|
||||
context: mediaContext,
|
||||
initialValue: { media: null, setMedia: () => {} },
|
||||
});
|
||||
|
||||
setMedia(media: Media | null) {
|
||||
this.#provider.setValue({ media, setMedia: () => {} });
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('test-cast-provider', TestMediaProvider);
|
||||
customElements.define('test-google-cast', GoogleCastElement);
|
||||
|
||||
function setup() {
|
||||
const host = new HTMLVideoElementHost();
|
||||
const provider = new TestMediaProvider();
|
||||
const el = new GoogleCastElement();
|
||||
|
||||
provider.append(el);
|
||||
document.body.append(provider);
|
||||
|
||||
return { host, provider, el };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('GoogleCastElement', () => {
|
||||
it('registers a GoogleCast component with the media host from context', () => {
|
||||
const { host, provider } = setup();
|
||||
|
||||
provider.setMedia(host as unknown as Media);
|
||||
|
||||
expect(getMediaComponents(host).get(GoogleCast)).toBeInstanceOf(GoogleCast);
|
||||
});
|
||||
|
||||
it('leaves the component to the base class lazy getter', () => {
|
||||
// An own `component` field would shadow the getter and be initialized after
|
||||
// the base constructor — too late for a connected upgrade, where the media
|
||||
// context callback registers the component from within that constructor.
|
||||
expect(Object.getOwnPropertyNames(new GoogleCastElement())).not.toContain('component');
|
||||
});
|
||||
|
||||
it('resolves the host from a media element host property', () => {
|
||||
const { host, provider } = setup();
|
||||
|
||||
provider.setMedia({ host } as unknown as Media);
|
||||
|
||||
expect(getMediaComponents(host).get(GoogleCast)).toBeInstanceOf(GoogleCast);
|
||||
});
|
||||
|
||||
it('ignores media that is not a media host', () => {
|
||||
const { host, provider } = setup();
|
||||
|
||||
provider.setMedia(host as unknown as Media);
|
||||
provider.setMedia(document.createElement('video') as unknown as Media);
|
||||
|
||||
expect(getMediaComponents(host).get(GoogleCast)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forwards attributes to the component', () => {
|
||||
const { host, provider, el } = setup();
|
||||
provider.setMedia(host as unknown as Media);
|
||||
|
||||
el.setAttribute('receiver', 'APP_ID');
|
||||
el.setAttribute('content-type', 'application/x-mpegURL');
|
||||
el.setAttribute('stream-type', 'live');
|
||||
el.setAttribute('src', 'https://example.com/stream.m3u8');
|
||||
|
||||
const component = getMediaComponents(host).get(GoogleCast)!;
|
||||
expect(component.receiver).toBe('APP_ID');
|
||||
expect(component.contentType).toBe('application/x-mpegURL');
|
||||
expect(component.streamType).toBe('live');
|
||||
expect(component.src).toBe('https://example.com/stream.m3u8');
|
||||
// Properties read back from the component.
|
||||
expect(el.receiver).toBe('APP_ID');
|
||||
});
|
||||
|
||||
it('clears a component prop when its attribute is removed', () => {
|
||||
const { el } = setup();
|
||||
|
||||
el.setAttribute('receiver', 'APP_ID');
|
||||
el.removeAttribute('receiver');
|
||||
|
||||
expect(el.receiver).toBeUndefined();
|
||||
});
|
||||
|
||||
it('moves the component when the media changes', () => {
|
||||
const { host, provider } = setup();
|
||||
const nextHost = new HTMLVideoElementHost();
|
||||
|
||||
provider.setMedia(host as unknown as Media);
|
||||
provider.setMedia(nextHost as unknown as Media);
|
||||
|
||||
expect(getMediaComponents(host).get(GoogleCast)).toBeUndefined();
|
||||
expect(getMediaComponents(nextHost).get(GoogleCast)).toBeInstanceOf(GoogleCast);
|
||||
});
|
||||
|
||||
it('removes the component when the element disconnects', () => {
|
||||
const { host, provider, el } = setup();
|
||||
provider.setMedia(host as unknown as Media);
|
||||
|
||||
el.remove();
|
||||
|
||||
expect(getMediaComponents(host).get(GoogleCast)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes the component on destroy', () => {
|
||||
const { host, provider, el } = setup();
|
||||
provider.setMedia(host as unknown as Media);
|
||||
|
||||
el.destroy();
|
||||
|
||||
expect(getMediaComponents(host).get(GoogleCast)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
import type { Media } from '@videojs/media/dom';
|
||||
import { getMediaComponents, type MediaComponent } from '@videojs/media/dom/media-host';
|
||||
import { HTMLVideoElementHost } from '@videojs/media/dom/video-host';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mediaContext } from '../../player/context';
|
||||
import { MediaElement } from '../../ui/media-element';
|
||||
import { MediaComponentElement } from '../media-component-element';
|
||||
|
||||
class FakeComponent implements MediaComponent {
|
||||
destroyed = false;
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
|
||||
class TestMediaProvider extends MediaElement {
|
||||
readonly #provider = new ContextProvider(this, {
|
||||
context: mediaContext,
|
||||
initialValue: { media: null, setMedia: () => {} },
|
||||
});
|
||||
|
||||
setMedia(media: Media | null) {
|
||||
this.#provider.setValue({ media, setMedia: () => {} });
|
||||
}
|
||||
}
|
||||
|
||||
class TestMediaComponentElement extends MediaComponentElement<FakeComponent> {
|
||||
static readonly tagName = 'test-media-component';
|
||||
|
||||
/**
|
||||
* Subclass field initializers run after the base constructor, which is the
|
||||
* window the media context callback can fire in during a custom element
|
||||
* upgrade. Reading the component here proves it resolves that early.
|
||||
*/
|
||||
readonly componentDuringFieldInit = this.component;
|
||||
|
||||
/** Exposes the protected component for assertions. */
|
||||
get instance(): FakeComponent {
|
||||
return this.component;
|
||||
}
|
||||
|
||||
protected createComponent(): FakeComponent {
|
||||
return new FakeComponent();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('test-media-component-provider', TestMediaProvider);
|
||||
customElements.define(TestMediaComponentElement.tagName, TestMediaComponentElement);
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('MediaComponentElement', () => {
|
||||
it('resolves the component before subclass fields initialize', () => {
|
||||
const el = new TestMediaComponentElement();
|
||||
|
||||
expect(el.componentDuringFieldInit).toBeInstanceOf(FakeComponent);
|
||||
// Created once and reused, not re-created per access.
|
||||
expect(el.componentDuringFieldInit).toBe(el.instance);
|
||||
});
|
||||
|
||||
it('registers the component with the media host from context', () => {
|
||||
const host = new HTMLVideoElementHost();
|
||||
const provider = new TestMediaProvider();
|
||||
const el = new TestMediaComponentElement();
|
||||
|
||||
provider.append(el);
|
||||
document.body.append(provider);
|
||||
provider.setMedia(host as unknown as Media);
|
||||
|
||||
expect(getMediaComponents(host).get(FakeComponent)).toBe(el.instance);
|
||||
});
|
||||
|
||||
it('destroys the component when the element is destroyed', () => {
|
||||
const host = new HTMLVideoElementHost();
|
||||
const provider = new TestMediaProvider();
|
||||
const el = new TestMediaComponentElement();
|
||||
|
||||
provider.append(el);
|
||||
document.body.append(provider);
|
||||
provider.setMedia(host as unknown as Media);
|
||||
|
||||
el.destroy();
|
||||
|
||||
expect(el.instance.destroyed).toBe(true);
|
||||
expect(getMediaComponents(host).get(FakeComponent)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not create a component when destroyed before use', () => {
|
||||
const el = new TestMediaComponentElement();
|
||||
// `componentDuringFieldInit` already forced creation, so assert through a
|
||||
// subclass that never touches it.
|
||||
class Untouched extends MediaComponentElement<FakeComponent> {
|
||||
created = 0;
|
||||
protected createComponent(): FakeComponent {
|
||||
this.created++;
|
||||
return new FakeComponent();
|
||||
}
|
||||
}
|
||||
customElements.define('test-media-component-untouched', Untouched);
|
||||
|
||||
const untouched = new Untouched();
|
||||
untouched.destroy();
|
||||
|
||||
expect(el.instance).toBeInstanceOf(FakeComponent);
|
||||
expect(untouched.created).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
import type { Media } from '@videojs/media/dom';
|
||||
import { getMediaComponents } from '@videojs/media/dom/media-host';
|
||||
import { MuxData } from '@videojs/media/dom/mux';
|
||||
import { HTMLVideoElementHost } from '@videojs/media/dom/video-host';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mediaContext } from '../../player/context';
|
||||
import { MediaElement } from '../../ui/media-element';
|
||||
import { MuxDataElement } from '../mux-data';
|
||||
|
||||
class TestMediaProvider extends MediaElement {
|
||||
readonly #provider = new ContextProvider(this, {
|
||||
context: mediaContext,
|
||||
initialValue: { media: null, setMedia: () => {} },
|
||||
});
|
||||
|
||||
setMedia(media: Media | null) {
|
||||
this.#provider.setValue({ media, setMedia: () => {} });
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('test-mux-data-provider', TestMediaProvider);
|
||||
customElements.define('test-mux-data', MuxDataElement);
|
||||
|
||||
function setup() {
|
||||
const host = new HTMLVideoElementHost();
|
||||
const provider = new TestMediaProvider();
|
||||
const el = new MuxDataElement();
|
||||
// Prevent the real Mux SDK from initializing (and beaconing) in tests.
|
||||
el.MuxDataSdk = undefined;
|
||||
|
||||
provider.append(el);
|
||||
document.body.append(provider);
|
||||
|
||||
return { host, provider, el };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('MuxDataElement', () => {
|
||||
it('registers when parsed into a connected player that already has media', () => {
|
||||
const host = new HTMLVideoElementHost();
|
||||
const provider = new TestMediaProvider();
|
||||
document.body.append(provider);
|
||||
provider.setMedia(host as unknown as Media);
|
||||
|
||||
provider.innerHTML = '<test-mux-data></test-mux-data>';
|
||||
|
||||
expect(getMediaComponents(host).get(MuxData)).toBeInstanceOf(MuxData);
|
||||
});
|
||||
|
||||
it('leaves the component to the base class lazy getter', () => {
|
||||
// An own `component` field would shadow the getter and be initialized after
|
||||
// the base constructor — too late for a connected upgrade, where the media
|
||||
// context callback registers the component from within that constructor.
|
||||
expect(Object.getOwnPropertyNames(new MuxDataElement())).not.toContain('component');
|
||||
});
|
||||
it('registers a MuxData component with the media host from context', () => {
|
||||
const { host, provider } = setup();
|
||||
|
||||
provider.setMedia(host as unknown as Media);
|
||||
|
||||
expect(getMediaComponents(host).get(MuxData)).toBeInstanceOf(MuxData);
|
||||
});
|
||||
|
||||
it('forwards attributes to the component', () => {
|
||||
const { host, provider, el } = setup();
|
||||
provider.setMedia(host as unknown as Media);
|
||||
|
||||
el.setAttribute('env-key', 'test-key');
|
||||
el.setAttribute('player-software-name', 'mux-video');
|
||||
el.setAttribute('player-init-time', '1234');
|
||||
el.setAttribute('debug', '');
|
||||
el.setAttribute('disable-cookies', '');
|
||||
|
||||
const component = getMediaComponents(host).get(MuxData)!;
|
||||
expect(component.envKey).toBe('test-key');
|
||||
expect(component.playerSoftwareName).toBe('mux-video');
|
||||
expect(component.playerInitTime).toBe(1234);
|
||||
expect(component.debug).toBe(true);
|
||||
expect(component.disableCookies).toBe(true);
|
||||
// Properties read back from the component.
|
||||
expect(el.envKey).toBe('test-key');
|
||||
});
|
||||
|
||||
it('forwards the metadata property to the component', () => {
|
||||
const { host, provider, el } = setup();
|
||||
provider.setMedia(host as unknown as Media);
|
||||
|
||||
const metadata = { video_title: 'Test' };
|
||||
el.metadata = metadata;
|
||||
|
||||
expect(getMediaComponents(host).get(MuxData)!.metadata).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('removes the component when the element disconnects', () => {
|
||||
const { host, provider, el } = setup();
|
||||
provider.setMedia(host as unknown as Media);
|
||||
|
||||
el.remove();
|
||||
|
||||
expect(getMediaComponents(host).get(MuxData)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getMediaComponents } from '@videojs/media/dom/media-host';
|
||||
import { MuxData } from '@videojs/media/dom/mux';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { MuxVideo } from '../mux-video';
|
||||
|
||||
@@ -7,8 +5,6 @@ customElements.define('test-mux-video', MuxVideo);
|
||||
|
||||
function createMuxVideo() {
|
||||
const el = new MuxVideo();
|
||||
// Prevent the real Mux SDK from initializing (and beaconing) in tests.
|
||||
el.config = { muxData: { MuxDataSdk: undefined } };
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
@@ -18,32 +14,12 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('MuxVideo', () => {
|
||||
it('constructs the mux data component with the player software name', () => {
|
||||
const el = createMuxVideo();
|
||||
const muxData = getMediaComponents(el.host).get(MuxData);
|
||||
|
||||
expect(muxData).toBeInstanceOf(MuxData);
|
||||
expect(muxData?.playerSoftwareName).toBe('mux-video');
|
||||
});
|
||||
|
||||
it('exposes the element config as plain values, not the component instance', () => {
|
||||
it('exposes the element config as a property, not an attribute', () => {
|
||||
const el = createMuxVideo();
|
||||
|
||||
// `config` reflects exactly what was set — a plain namespace bag.
|
||||
expect(el.config.muxData).toEqual({ MuxDataSdk: undefined });
|
||||
expect(el.config.muxData).not.toBeInstanceOf(MuxData);
|
||||
});
|
||||
el.config = { preferPlayback: 'native' };
|
||||
|
||||
it('routes component config writes to the component', () => {
|
||||
const el = createMuxVideo();
|
||||
const muxData = getMediaComponents(el.host).get(MuxData);
|
||||
|
||||
el.config = { muxData: { envKey: 'test-key' } };
|
||||
|
||||
// The write reached the live component...
|
||||
expect(muxData?.envKey).toBe('test-key');
|
||||
// ...and config reads back the plain value, not the instance.
|
||||
expect(el.config.muxData?.envKey).toBe('test-key');
|
||||
expect(el.config.preferPlayback).toBe('native');
|
||||
expect(el.hasAttribute('config')).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,8 +31,10 @@ const presets = [
|
||||
'background',
|
||||
];
|
||||
const media = [
|
||||
'google-cast',
|
||||
'hlsjs-video',
|
||||
'mux-audio',
|
||||
'mux-data',
|
||||
'mux-video',
|
||||
'native-hls-video',
|
||||
'simple-hls-audio-only',
|
||||
|
||||
Reference in New Issue
Block a user