diff --git a/apps/sandbox/templates/html-mux-audio/main.ts b/apps/sandbox/templates/html-mux-audio/main.ts
index 8f299611..dd783418 100644
--- a/apps/sandbox/templates/html-mux-audio/main.ts
+++ b/apps/sandbox/templates/html-mux-audio/main.ts
@@ -1,7 +1,9 @@
import '@app/styles.css';
import { bindSandboxHtmlLocaleChange, prepareSandboxHtmlLocale, wrapSandboxHtmlI18n } from '@app/shared/html/i18n';
import '@videojs/html/audio/player';
+import '@videojs/html/media/google-cast';
import '@videojs/html/media/mux-audio';
+import '@videojs/html/media/mux-data';
import { createHtmlSandboxState, createLatestLoader, renderMediaAttrs } from '@app/shared/html/sandbox-state';
import { loadAudioSkinTag } from '@app/shared/html/skins';
import {
@@ -32,6 +34,9 @@ async function render() {
<${tag}>
+
+
+
${tag}>
diff --git a/apps/sandbox/templates/html-mux-video/main.ts b/apps/sandbox/templates/html-mux-video/main.ts
index 82b8ad2a..dc6cdd35 100644
--- a/apps/sandbox/templates/html-mux-video/main.ts
+++ b/apps/sandbox/templates/html-mux-video/main.ts
@@ -1,6 +1,8 @@
import '@app/styles.css';
import { bindSandboxHtmlLocaleChange, prepareSandboxHtmlLocale, wrapSandboxHtmlI18n } from '@app/shared/html/i18n';
import '@videojs/html/video/player';
+import '@videojs/html/media/google-cast';
+import '@videojs/html/media/mux-data';
import '@videojs/html/media/mux-video';
import { createHtmlSandboxState, createLatestLoader, renderMediaAttrs } from '@app/shared/html/sandbox-state';
import { loadVideoSkinTag } from '@app/shared/html/skins';
@@ -36,6 +38,9 @@ async function render() {
<${tag} class="aspect-video max-w-4xl mx-auto"${placeholder ? ` placeholdersrc="${placeholder}"` : ''}>
+
+
+
${poster ? html`
` : ''}
${tag}>
${playerTag}>
diff --git a/apps/sandbox/templates/react-mux-audio/main.tsx b/apps/sandbox/templates/react-mux-audio/main.tsx
index bce8a735..ce320380 100644
--- a/apps/sandbox/templates/react-mux-audio/main.tsx
+++ b/apps/sandbox/templates/react-mux-audio/main.tsx
@@ -10,7 +10,9 @@ import { useSkin } from '@app/shared/react/use-skin';
import { useSource } from '@app/shared/react/use-source';
import { SOURCES } from '@app/shared/sources';
import type { Styling } from '@app/types';
+import { GoogleCast } from '@videojs/react/media/google-cast';
import { MuxAudio } from '@videojs/react/media/mux-audio';
+import { MuxData } from '@videojs/react/media/mux-data';
import { useMemo } from 'react';
import { createRoot } from 'react-dom/client';
@@ -39,6 +41,9 @@ function App() {
preload={preload}
crossOrigin="anonymous"
/>
+ {/* Mux Data and Cast are opt-in media components; no env key is needed for Mux-hosted sources. */}
+
+
diff --git a/apps/sandbox/templates/react-mux-video/main.tsx b/apps/sandbox/templates/react-mux-video/main.tsx
index 65f82844..76c05a0e 100644
--- a/apps/sandbox/templates/react-mux-video/main.tsx
+++ b/apps/sandbox/templates/react-mux-video/main.tsx
@@ -12,6 +12,8 @@ import { useSkin } from '@app/shared/react/use-skin';
import { useSource } from '@app/shared/react/use-source';
import { isLiveSource, SOURCES } from '@app/shared/sources';
import type { Styling } from '@app/types';
+import { GoogleCast } from '@videojs/react/media/google-cast';
+import { MuxData } from '@videojs/react/media/mux-data';
import { MuxVideo } from '@videojs/react/media/mux-video';
import { useMemo } from 'react';
import { createRoot } from 'react-dom/client';
@@ -54,6 +56,9 @@ function App() {
playsInline
crossOrigin="anonymous"
/>
+ {/* Mux Data and Cast are opt-in media components; no env key is needed for Mux-hosted sources. */}
+
+
diff --git a/packages/html/src/cdn/media/google-cast.ts b/packages/html/src/cdn/media/google-cast.ts
new file mode 100644
index 00000000..c326e8f3
--- /dev/null
+++ b/packages/html/src/cdn/media/google-cast.ts
@@ -0,0 +1 @@
+import '../../define/media/google-cast';
diff --git a/packages/html/src/cdn/media/mux-data.ts b/packages/html/src/cdn/media/mux-data.ts
new file mode 100644
index 00000000..a816707a
--- /dev/null
+++ b/packages/html/src/cdn/media/mux-data.ts
@@ -0,0 +1 @@
+import '../../define/media/mux-data';
diff --git a/packages/html/src/define/media/google-cast.ts b/packages/html/src/define/media/google-cast.ts
new file mode 100644
index 00000000..4fe0dcca
--- /dev/null
+++ b/packages/html/src/define/media/google-cast.ts
@@ -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;
+ }
+}
diff --git a/packages/html/src/define/media/mux-data.ts b/packages/html/src/define/media/mux-data.ts
new file mode 100644
index 00000000..1c0b8d2d
--- /dev/null
+++ b/packages/html/src/define/media/mux-data.ts
@@ -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;
+ }
+}
diff --git a/packages/html/src/media/dash-video/media.ts b/packages/html/src/media/dash-video/media.ts
index 11d138f3..2b10d2cb 100644
--- a/packages/html/src/media/dash-video/media.ts
+++ b/packages/html/src/media/dash-video/media.ts
@@ -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)) {}
diff --git a/packages/html/src/media/google-cast/google-cast-element.ts b/packages/html/src/media/google-cast/google-cast-element.ts
new file mode 100644
index 00000000..4c4f2385
--- /dev/null
+++ b/packages/html/src/media/google-cast/google-cast-element.ts
@@ -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
+ *
+ *
+ *
+ *
+ * ```
+ */
+export class GoogleCastElement extends MediaComponentElement {
+ 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>;
+
+ 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;
+ }
+}
diff --git a/packages/html/src/media/google-cast/index.ts b/packages/html/src/media/google-cast/index.ts
new file mode 100644
index 00000000..ed0be4a9
--- /dev/null
+++ b/packages/html/src/media/google-cast/index.ts
@@ -0,0 +1 @@
+export * from './google-cast-element';
diff --git a/packages/html/src/media/hlsjs-video/media.ts b/packages/html/src/media/hlsjs-video/media.ts
index 17591011..3c872177 100644
--- a/packages/html/src/media/hlsjs-video/media.ts
+++ b/packages/html/src/media/hlsjs-video/media.ts
@@ -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)) {}
diff --git a/packages/html/src/media/media-component-element.ts b/packages/html/src/media/media-component-element.ts
new file mode 100644
index 00000000..970867b3
--- /dev/null
+++ b/packages/html/src/media/media-component-element.ts
@@ -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;
+
+/** 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 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);
+ }
+}
diff --git a/packages/html/src/media/mux-audio/media.ts b/packages/html/src/media/mux-audio/media.ts
index f69d2cfa..44422d27 100644
--- a/packages/html/src/media/mux-audio/media.ts
+++ b/packages/html/src/media/mux-audio/media.ts
@@ -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());
}
diff --git a/packages/html/src/media/mux-data/index.ts b/packages/html/src/media/mux-data/index.ts
new file mode 100644
index 00000000..e3178228
--- /dev/null
+++ b/packages/html/src/media/mux-data/index.ts
@@ -0,0 +1 @@
+export * from './mux-data-element';
diff --git a/packages/html/src/media/mux-data/mux-data-element.ts b/packages/html/src/media/mux-data/mux-data-element.ts
new file mode 100644
index 00000000..effc2aaa
--- /dev/null
+++ b/packages/html/src/media/mux-data/mux-data-element.ts
@@ -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
+ *
+ *
+ *
+ *
+ * ```
+ */
+export class MuxDataElement extends MediaComponentElement {
+ 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>;
+
+ 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;
+ }
+}
diff --git a/packages/html/src/media/mux-video/media.ts b/packages/html/src/media/mux-video/media.ts
index 2e1d587d..e656c2e7 100644
--- a/packages/html/src/media/mux-video/media.ts
+++ b/packages/html/src/media/mux-video/media.ts
@@ -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).
diff --git a/packages/html/src/media/native-hls-video/media.ts b/packages/html/src/media/native-hls-video/media.ts
index b7809068..5e545ce0 100644
--- a/packages/html/src/media/native-hls-video/media.ts
+++ b/packages/html/src/media/native-hls-video/media.ts
@@ -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)) {}
diff --git a/packages/html/src/media/tests/google-cast.test.ts b/packages/html/src/media/tests/google-cast.test.ts
new file mode 100644
index 00000000..b854eeeb
--- /dev/null
+++ b/packages/html/src/media/tests/google-cast.test.ts
@@ -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();
+ });
+});
diff --git a/packages/html/src/media/tests/media-component-element.test.ts b/packages/html/src/media/tests/media-component-element.test.ts
new file mode 100644
index 00000000..544bfad0
--- /dev/null
+++ b/packages/html/src/media/tests/media-component-element.test.ts
@@ -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 {
+ 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 {
+ 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);
+ });
+});
diff --git a/packages/html/src/media/tests/mux-data.test.ts b/packages/html/src/media/tests/mux-data.test.ts
new file mode 100644
index 00000000..6702045c
--- /dev/null
+++ b/packages/html/src/media/tests/mux-data.test.ts
@@ -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 = '';
+
+ 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();
+ });
+});
diff --git a/packages/html/src/media/tests/mux-video.test.ts b/packages/html/src/media/tests/mux-video.test.ts
index 20563d78..9dbbced6 100644
--- a/packages/html/src/media/tests/mux-video.test.ts
+++ b/packages/html/src/media/tests/mux-video.test.ts
@@ -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);
});
diff --git a/packages/html/tsdown.cdn.config.ts b/packages/html/tsdown.cdn.config.ts
index 23dd5b46..e95ed33b 100644
--- a/packages/html/tsdown.cdn.config.ts
+++ b/packages/html/tsdown.cdn.config.ts
@@ -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',
diff --git a/packages/media/src/dom/google-cast/media.ts b/packages/media/src/dom/google-cast/media.ts
index 86340efe..0d8699c0 100644
--- a/packages/media/src/dom/google-cast/media.ts
+++ b/packages/media/src/dom/google-cast/media.ts
@@ -18,15 +18,15 @@ export interface GoogleCastProps {
customData?: Record | null | undefined;
}
-declare module '../media-host' {
- interface MediaComponentConfig {
- googleCast: GoogleCastProps;
- }
-}
+export const googleCastDefaultProps: GoogleCastProps = {
+ src: undefined,
+ contentType: undefined,
+ streamType: undefined,
+ receiver: undefined,
+ customData: undefined,
+};
export class GoogleCast implements GoogleCastProps, MediaComponent {
- static readonly configKey = 'googleCast';
-
#src: string | undefined;
#contentType: string | undefined;
#streamType: MediaStreamType | undefined;
@@ -45,10 +45,12 @@ export class GoogleCast implements GoogleCastProps, MediaComponent {
this.#media = host;
- this.#provider ??= new GoogleCastProvider(this);
- this.#override = this.#createRemoteOverride();
- this.#provider.remote.addEventListener('connect', this.#onStateChange);
- this.#provider.remote.addEventListener('disconnect', this.#onStateChange);
+ if (!this.#provider) {
+ this.#provider = new GoogleCastProvider(this);
+ this.#provider.remote.addEventListener('connect', this.#onStateChange);
+ this.#provider.remote.addEventListener('disconnect', this.#onStateChange);
+ this.#override = this.#createRemoteOverride();
+ }
}
attach(target: HTMLMediaTargetLike) {
@@ -93,7 +95,7 @@ export class GoogleCast implements GoogleCastProps, MediaComponent {
return this.#src ?? this.#media?.querySelector('source')?.src ?? this.#media?.src ?? this.#media?.currentSrc ?? '';
}
- set src(value: string) {
+ set src(value: string | undefined) {
if (this.#src === value) return;
this.#src = value;
this.#load();
diff --git a/packages/media/src/dom/google-cast/tests/media.test.ts b/packages/media/src/dom/google-cast/tests/media.test.ts
index b87198ed..97a463c5 100644
--- a/packages/media/src/dom/google-cast/tests/media.test.ts
+++ b/packages/media/src/dom/google-cast/tests/media.test.ts
@@ -6,6 +6,16 @@ import { GoogleCast } from '../index';
const mocks = vi.hoisted(() => {
class FakeRemote extends EventTarget {
state: 'disconnected' | 'connecting' | 'connected' = 'disconnected';
+ listenerCounts = new Map();
+
+ override addEventListener(
+ type: string,
+ listener: EventListenerOrEventListenerObject | null,
+ options?: boolean | AddEventListenerOptions
+ ) {
+ this.listenerCounts.set(type, (this.listenerCounts.get(type) ?? 0) + 1);
+ super.addEventListener(type, listener, options);
+ }
}
class FakeProvider {
@@ -64,6 +74,26 @@ afterEach(() => {
});
describe('GoogleCast', () => {
+ it('registers remote state listeners only once across media changes', () => {
+ const { googleCast, provider } = setup();
+ const nextHost = new HTMLVideoElementHost();
+
+ googleCast.setMedia(nextHost);
+
+ expect(provider.remote.listenerCounts.get('connect')).toBe(1);
+ expect(provider.remote.listenerCounts.get('disconnect')).toBe(1);
+ });
+
+ it('keeps the provider override when media changes during a connected session', () => {
+ const { googleCast, provider } = setup();
+ const nextHost = new HTMLVideoElementHost();
+
+ connect(provider);
+ googleCast.setMedia(nextHost);
+
+ expect(googleCast.targetOverride).toBe(provider);
+ });
+
describe('override swap on connect/disconnect', () => {
it('routes host reads to the target while disconnected', () => {
const { host, googleCast, provider } = setup();
diff --git a/packages/media/src/dom/media-host/media-host.ts b/packages/media/src/dom/media-host/media-host.ts
index ee308ab2..7806d336 100644
--- a/packages/media/src/dom/media-host/media-host.ts
+++ b/packages/media/src/dom/media-host/media-host.ts
@@ -34,7 +34,6 @@ export interface MediaComponent {
new (...args: any[]): T;
- readonly configKey?: string;
}
export interface MediaComponents extends Map {
@@ -42,11 +41,8 @@ export interface MediaComponents extends Map(component: MediaComponentConstructor, instance: T): this;
}
-// biome-ignore lint/suspicious/noEmptyInterface: augmentation target for component config namespaces
-export interface MediaComponentConfig {}
-
-/** Host config bag: free-form host/engine settings plus per-component config namespaces. */
-export type MediaConfig = Partial & Record;
+/** Host config bag for host/engine settings. Media components are configured directly. */
+export type MediaConfig = Record;
export class HTMLMediaElementHost
extends EventTarget
@@ -91,12 +87,11 @@ export class HTMLMediaElementHost`,
+ // ``), which may outlive this host. `detach()` above releases
+ // them from the target, so only drop the registrations here and leave
+ // destruction to the owner.
+ getMediaComponents(this).clear();
}
querySelectorAll(selectors: S) {
@@ -159,12 +154,6 @@ export class HTMLMediaElementHost;
- }
-}
-
export class MuxData implements MuxDataProps {
- static readonly configKey = 'muxData';
-
- #MuxDataSdk: MuxDataSdk | undefined = Mux;
+ #MuxDataSdk: MuxDataSdk | undefined = muxDataDefaultProps.MuxDataSdk;
#pendingInitialize: Promise | null = null;
- #beaconCollectionDomain: string | undefined;
- #debug = false;
- #disableCookies = false;
- #metadata: MuxDataOptions['data'] | undefined;
- #envKey: string | undefined;
- #playerSoftwareName: string | undefined;
- #playerSoftwareVersion: string | undefined = getPlayerVersion();
+ #beaconCollectionDomain: string | undefined = muxDataDefaultProps.beaconCollectionDomain;
+ #debug = muxDataDefaultProps.debug;
+ #disableCookies = muxDataDefaultProps.disableCookies;
+ #metadata: MuxDataOptions['data'] | undefined = muxDataDefaultProps.metadata;
+ #envKey: string | undefined = muxDataDefaultProps.envKey;
+ #playerSoftwareName: string | undefined = muxDataDefaultProps.playerSoftwareName;
+ #playerSoftwareVersion: string | undefined = muxDataDefaultProps.playerSoftwareVersion;
#playerInitTime: number | undefined = this.#generatePlayerInitTime();
#media: MuxDataMedia | null = null;
#target: HTMLVideoElement | null = null;
@@ -49,6 +54,8 @@ export class MuxData implements MuxDataProps {
}
setMedia(media: MuxDataMedia) {
+ if (this.#media === media) return;
+ this.#media?.removeEventListener('loadstart', this.#reinitialize);
this.#media = media;
this.#media.addEventListener('loadstart', this.#reinitialize);
}
@@ -67,9 +74,9 @@ export class MuxData implements MuxDataProps {
}
destroy() {
+ this.detach();
this.#media?.removeEventListener('loadstart', this.#reinitialize);
this.#media = null;
- this.#target = null;
}
get MuxDataSdk() {
@@ -112,6 +119,12 @@ export class MuxData implements MuxDataProps {
this.#reinitialize();
}
+ /**
+ * Mux Data environment key. Omitted from the beacon when unset, which is the
+ * norm for Mux-hosted playback: the view reports the Mux playback ID as its
+ * `video_id` (see {@link toVideoId}) and Mux attributes it to the owning
+ * environment. Set this to monitor sources Mux doesn't host.
+ */
get envKey() {
return this.#envKey;
}
diff --git a/packages/media/src/dom/mux/tests/mux-data.test.ts b/packages/media/src/dom/mux/tests/mux-data.test.ts
index 24a6261b..a9370530 100644
--- a/packages/media/src/dom/mux/tests/mux-data.test.ts
+++ b/packages/media/src/dom/mux/tests/mux-data.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
-import { HlsJsMedia } from '../../hls-js';
-import { addMediaComponent } from '../../media-host';
+import type { HlsJsMedia } from '../../hls-js';
import { MuxData } from '..';
import type { MuxDataSdk } from '../types';
@@ -92,24 +91,38 @@ describe('MuxData', () => {
);
});
- it('exposes mux config under host.config.muxData with inferred types', () => {
- const media = new HlsJsMedia();
- const muxData = new MuxData();
- addMediaComponent(media, muxData);
+ it('moves its media listener when registered with another host', async () => {
+ const { sdk, monitor } = createSdk();
+ const data = new MuxData({ MuxDataSdk: sdk });
+ const video = document.createElement('video');
+ const first = new FakeMedia();
+ const second = new FakeMedia();
- // Type-level: `config.muxData` infers `Partial` via the
- // 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;
+ data.setMedia(first);
+ data.attach(video);
+ await settle();
- expect(envKey).toBe('key');
- // `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);
+ data.setMedia(second);
+ first.dispatchEvent(new Event('loadstart'));
+ await settle();
+ expect(monitor).toHaveBeenCalledTimes(1);
+
+ second.dispatchEvent(new Event('loadstart'));
+ await settle();
+ expect(monitor).toHaveBeenCalledTimes(2);
+ });
+
+ it('destroys active monitoring on destroy', () => {
+ const data = new MuxData();
+ const video = document.createElement('video');
+ const destroy = vi.fn();
+ Object.defineProperty(video, 'mux', { value: { destroy }, writable: true, configurable: true });
+
+ data.attach(video);
+ data.destroy();
+
+ expect(destroy).toHaveBeenCalledTimes(1);
+ expect(video.mux).toBeUndefined();
});
it('stops re-monitoring after destroy', async () => {
diff --git a/packages/media/src/dom/tests/media-components.test.ts b/packages/media/src/dom/tests/media-components.test.ts
index 8d990572..27e57dc6 100644
--- a/packages/media/src/dom/tests/media-components.test.ts
+++ b/packages/media/src/dom/tests/media-components.test.ts
@@ -1,11 +1,47 @@
import { afterEach, describe, expect, it } from 'vitest';
import { HTMLAudioElementHost } from '../audio-host';
-import { getMediaProp } from '../utils';
+import type { MediaComponent } from '../media-host';
+import { addMediaComponent, getMediaProp } from '../utils';
afterEach(() => {
document.body.innerHTML = '';
});
+class DetachableComponent implements MediaComponent {
+ detachCount = 0;
+ detach() {
+ this.detachCount++;
+ }
+}
+
+describe('addMediaComponent', () => {
+ it('detaches the component when it is unregistered', () => {
+ const host = new HTMLAudioElementHost();
+ host.attach(document.createElement('audio'));
+ const component = new DetachableComponent();
+
+ const remove = addMediaComponent(host, component);
+ remove();
+
+ expect(component.detachCount).toBe(1);
+ });
+
+ it('detaches the previous component when another instance replaces it', () => {
+ const host = new HTMLAudioElementHost();
+ host.attach(document.createElement('audio'));
+ const first = new DetachableComponent();
+ const second = new DetachableComponent();
+
+ const removeFirst = addMediaComponent(host, first);
+ addMediaComponent(host, second);
+ removeFirst();
+
+ expect(first.detachCount).toBe(1);
+ // The stale cleanup does not detach the replacement.
+ expect(second.detachCount).toBe(0);
+ });
+});
+
describe('getMediaProp', () => {
it('returns the owner value', () => {
const host = new HTMLAudioElementHost();
diff --git a/packages/media/src/dom/tests/media-host.test.ts b/packages/media/src/dom/tests/media-host.test.ts
index b51b2f4e..ca6045dc 100644
--- a/packages/media/src/dom/tests/media-host.test.ts
+++ b/packages/media/src/dom/tests/media-host.test.ts
@@ -20,6 +20,7 @@ class VolumeOverride implements MediaComponent {
class AttachTracking implements MediaComponent {
attach = vi.fn();
+ detach = vi.fn();
destroy = vi.fn();
}
@@ -38,13 +39,6 @@ class CastLikeOverride implements MediaComponent {
}
}
-class ConfigurableComponent implements MediaComponent {
- static readonly configKey = 'fake';
- value = 0;
- label = '';
- destroy() {}
-}
-
describe('HTMLMediaElementHost', () => {
describe('component overrides', () => {
it('returns the override value when a component exposes the property', () => {
@@ -142,7 +136,7 @@ describe('HTMLMediaElementHost', () => {
expect(component.attach).not.toHaveBeenCalled();
});
- it('destroys and unregisters components on destroy', () => {
+ it('detaches and unregisters components on destroy', () => {
const host = new HTMLAudioElementHost();
const audio = document.createElement('audio');
audio.muted = false;
@@ -154,13 +148,26 @@ describe('HTMLMediaElementHost', () => {
host.destroy();
- expect(component.destroy).toHaveBeenCalledTimes(1);
+ expect(component.detach).toHaveBeenCalledTimes(1);
- // The destroyed override no longer participates in property resolution.
+ // The unregistered override no longer participates in property resolution.
host.attach(audio);
expect(host.muted).toBe(false);
});
+ it('does not destroy components it does not own on destroy', () => {
+ const host = new HTMLAudioElementHost();
+ host.attach(document.createElement('audio'));
+
+ const component = new AttachTracking();
+ addMediaComponent(host, component);
+
+ host.destroy();
+
+ // `` / `MuxData` own their component and may outlive the host.
+ expect(component.destroy).not.toHaveBeenCalled();
+ });
+
it('invokes the override method when it owns the property', async () => {
const host = new HTMLAudioElementHost();
const audio = document.createElement('audio');
@@ -190,64 +197,16 @@ describe('HTMLMediaElementHost', () => {
});
});
- describe('component config binding', () => {
- it('applies a component namespace onto the component when config is set', () => {
- const host = new HTMLAudioElementHost();
- const component = new ConfigurableComponent();
- addMediaComponent(host, component);
-
- host.config = { fake: { value: 3, label: 'a' } };
-
- expect(component.value).toBe(3);
- expect(component.label).toBe('a');
- });
-
- it('stores config as plain values, never component instances', () => {
- const host = new HTMLAudioElementHost();
- addMediaComponent(host, new ConfigurableComponent());
-
- 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 });
- });
-
+ describe('config', () => {
it('returns the same object that was assigned', () => {
const host = new HTMLAudioElementHost();
- const value = { fake: { value: 1 }, a: 2 };
+ const value = { hlsJs: { debug: true }, a: 2 };
host.config = value;
expect(host.config).toBe(value);
});
- it('round-trips through JSON without leaking component instances', () => {
- const host = new HTMLAudioElementHost();
- addMediaComponent(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();
- addMediaComponent(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();
@@ -258,72 +217,5 @@ describe('HTMLMediaElementHost', () => {
expect(host.config.a).toBeUndefined();
expect(host.config.b).toBe(2);
});
-
- it('keeps component state when a later config omits its namespace', () => {
- const host = new HTMLAudioElementHost();
- const component = new ConfigurableComponent();
- addMediaComponent(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();
- addMediaComponent(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 = addMediaComponent(host, component);
-
- remove();
- host.config = { fake: { value: 7 } };
-
- expect(component.value).toBe(0);
- });
-
- it('adopts config set before the component was registered', () => {
- const host = new HTMLAudioElementHost();
- host.config = { fake: { value: 4, label: 'early' } };
-
- const component = new ConfigurableComponent();
- addMediaComponent(host, component);
-
- expect(component.value).toBe(4);
- expect(component.label).toBe('early');
- // 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();
- addMediaComponent(host, component);
-
- expect(component.value).toBe(0);
- expect(component.label).toBe('');
- expect(host.config.fake).toBeUndefined();
- });
});
});
diff --git a/packages/media/src/dom/utils/media-components.ts b/packages/media/src/dom/utils/media-components.ts
index dad9c32a..5d7c16c2 100644
--- a/packages/media/src/dom/utils/media-components.ts
+++ b/packages/media/src/dom/utils/media-components.ts
@@ -21,14 +21,11 @@ export function addMediaComponent(host: MediaHost, com
// Get the component's constructor to use as the key for the component in the registry.
const ctor = component.constructor as MediaComponentConstructor;
- // Adopt any config set under this namespace before the component registered.
- const { configKey } = ctor;
- const staged = configKey ? host.config[configKey] : undefined;
+ const previous = components.get(ctor);
+ if (previous && previous !== component) previous.detach?.();
components.set(ctor, component);
- if (staged !== undefined) Object.assign(component, staged);
-
component.setMedia?.(host);
// @ts-expect-error `target` is protected, but these helpers are the host's own machinery.
@@ -36,6 +33,7 @@ export function addMediaComponent(host: MediaHost, com
return () => {
if (components.get(ctor) === component) {
+ component.detach?.();
components.delete(ctor);
}
};
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index f9688099..1ae5e193 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -158,5 +158,6 @@ export { useAttachMedia } from './utils/use-attach-media';
export { composeRefs, useComposedRefs } from './utils/use-composed-refs';
export { useDestroy } from './utils/use-destroy';
export { useLatestRef } from './utils/use-latest-ref';
+export { useMediaComponent } from './utils/use-media-component';
export { useMediaInstance } from './utils/use-media-instance';
export { renderElement } from './utils/use-render';
diff --git a/packages/react/src/media/dash-video/media.tsx b/packages/react/src/media/dash-video/media.tsx
index 0cdbd8ed..5d74730a 100644
--- a/packages/react/src/media/dash-video/media.tsx
+++ b/packages/react/src/media/dash-video/media.tsx
@@ -2,8 +2,6 @@
import type { DashMediaProps } from '@videojs/media/dom/dash';
import { DashMedia, dashMediaDefaultProps } from '@videojs/media/dom/dash';
-import { GoogleCast } from '@videojs/media/dom/google-cast';
-import { addMediaComponent } from '@videojs/media/dom/media-host';
import type { ReactNode, VideoHTMLAttributes } from 'react';
import { forwardRef } from 'react';
import { useAttachMedia } from '../../utils/use-attach-media';
@@ -18,9 +16,7 @@ export interface DashVideoProps
}
export const DashVideo = forwardRef(function DashVideo({ children, ...props }, ref) {
- const media = useMediaInstance(DashMedia, (media) => {
- addMediaComponent(media, new GoogleCast());
- });
+ const media = useMediaInstance(DashMedia);
const attachRef = useAttachMedia(media);
const composedRef = useComposedRefs(attachRef, ref);
const htmlProps = useSyncProps(media, props, dashMediaDefaultProps);
diff --git a/packages/react/src/media/google-cast/google-cast.tsx b/packages/react/src/media/google-cast/google-cast.tsx
new file mode 100644
index 00000000..7d745b6e
--- /dev/null
+++ b/packages/react/src/media/google-cast/google-cast.tsx
@@ -0,0 +1,37 @@
+'use client';
+
+import type { GoogleCastProps as GoogleCastComponentProps } from '@videojs/media/dom/google-cast';
+import { GoogleCast as GoogleCastComponent, googleCastDefaultProps } from '@videojs/media/dom/google-cast';
+import type { ReactNode } from 'react';
+
+import { useMediaComponent } from '../../utils/use-media-component';
+import { useSyncProps } from '../../utils/use-sync-props';
+
+export type GoogleCastProps = Partial;
+
+/**
+ * Adds Google Cast support to the surrounding player's media.
+ *
+ * Renders nothing — place it inside the player provider as a sibling of the
+ * media component (e.g. ``) and it registers a `GoogleCast`
+ * media component with the active media.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ * ```
+ */
+export function GoogleCast(props: GoogleCastProps): ReactNode {
+ const component = useMediaComponent(GoogleCastComponent);
+
+ useSyncProps(component, props, googleCastDefaultProps);
+
+ return null;
+}
+
+export namespace GoogleCast {
+ export type Props = GoogleCastProps;
+}
diff --git a/packages/react/src/media/google-cast/index.ts b/packages/react/src/media/google-cast/index.ts
new file mode 100644
index 00000000..a7898563
--- /dev/null
+++ b/packages/react/src/media/google-cast/index.ts
@@ -0,0 +1 @@
+export * from './google-cast';
diff --git a/packages/react/src/media/hlsjs-video/media.tsx b/packages/react/src/media/hlsjs-video/media.tsx
index fa202c28..e39ffbcc 100644
--- a/packages/react/src/media/hlsjs-video/media.tsx
+++ b/packages/react/src/media/hlsjs-video/media.tsx
@@ -1,9 +1,7 @@
'use client';
-import { GoogleCast } from '@videojs/media/dom/google-cast';
import type { HlsMediaProps } from '@videojs/media/dom/hls-js';
import { HlsJsMedia, hlsMediaDefaultProps } from '@videojs/media/dom/hls-js';
-import { addMediaComponent } from '@videojs/media/dom/media-host';
import type { ReactNode, VideoHTMLAttributes } from 'react';
import { forwardRef } from 'react';
import { useAttachMedia } from '../../utils/use-attach-media';
@@ -21,9 +19,7 @@ export const HlsJsVideo = forwardRef(function
{ children, ...props },
ref
) {
- const media = useMediaInstance(HlsJsMedia, (media) => {
- addMediaComponent(media, new GoogleCast());
- });
+ const media = useMediaInstance(HlsJsMedia);
const attachRef = useAttachMedia(media);
const composedRef = useComposedRefs(attachRef, ref);
const htmlProps = useSyncProps(media, props, hlsMediaDefaultProps);
diff --git a/packages/react/src/media/mux-audio/media.tsx b/packages/react/src/media/mux-audio/media.tsx
index 392ac5ef..905c6543 100644
--- a/packages/react/src/media/mux-audio/media.tsx
+++ b/packages/react/src/media/mux-audio/media.tsx
@@ -1,11 +1,9 @@
'use client';
-import { GoogleCast } from '@videojs/media/dom/google-cast';
import type { HlsMediaProps } from '@videojs/media/dom/hls-js';
import { hlsMediaDefaultProps } from '@videojs/media/dom/hls-js';
-import { addMediaComponent } from '@videojs/media/dom/media-host';
import type { MuxMediaProps } from '@videojs/media/dom/mux';
-import { MuxData, MuxMedia, muxMediaDefaultProps } from '@videojs/media/dom/mux';
+import { MuxMedia, muxMediaDefaultProps } from '@videojs/media/dom/mux';
import type { AudioHTMLAttributes, ReactNode } from 'react';
import { forwardRef } from 'react';
import { useAttachMedia } from '../../utils/use-attach-media';
@@ -23,10 +21,7 @@ export interface MuxAudioProps
const muxAudioDefaultProps: HlsMediaProps & MuxMediaProps = { ...hlsMediaDefaultProps, ...muxMediaDefaultProps };
export const MuxAudio = forwardRef(function MuxAudio({ children, ...props }, ref) {
- const media = useMediaInstance(MuxMedia, (media) => {
- addMediaComponent(media, new MuxData({ playerSoftwareName: 'mux-audio' }));
- addMediaComponent(media, new GoogleCast());
- });
+ const media = useMediaInstance(MuxMedia);
const attachRef = useAttachMedia(media);
const composedRef = useComposedRefs(attachRef, ref);
const htmlProps = useSyncProps(media, props, muxAudioDefaultProps);
diff --git a/packages/react/src/media/mux-data/index.ts b/packages/react/src/media/mux-data/index.ts
new file mode 100644
index 00000000..d59d087c
--- /dev/null
+++ b/packages/react/src/media/mux-data/index.ts
@@ -0,0 +1 @@
+export * from './mux-data';
diff --git a/packages/react/src/media/mux-data/mux-data.tsx b/packages/react/src/media/mux-data/mux-data.tsx
new file mode 100644
index 00000000..e8434957
--- /dev/null
+++ b/packages/react/src/media/mux-data/mux-data.tsx
@@ -0,0 +1,50 @@
+'use client';
+
+import type { MuxDataProps as MuxDataComponentProps } from '@videojs/media/dom/mux';
+import { MuxData as MuxDataComponent, muxDataDefaultProps } from '@videojs/media/dom/mux';
+import type { ReactNode } from 'react';
+
+import { useMediaComponent } from '../../utils/use-media-component';
+import { useSyncProps } from '../../utils/use-sync-props';
+
+export type MuxDataProps = Partial;
+
+/**
+ * Adds [Mux Data](https://www.mux.com/data) monitoring to the surrounding
+ * player's media.
+ *
+ * Renders nothing — place it inside the player provider as a sibling of the
+ * media component (e.g. ``) and it registers a `MuxData` media
+ * component with the active media.
+ *
+ * Mux-hosted playback needs no `envKey`: the view reports the Mux playback ID
+ * as its `video_id`, which Mux attributes to the owning environment. Set
+ * `envKey` to monitor sources Mux doesn't host.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ * ```
+ */
+export function MuxData(props: MuxDataProps): ReactNode {
+ const component = useMediaComponent(MuxDataComponent);
+ const { MuxDataSdk, ...rest } = props;
+
+ // `useSyncProps` treats an `undefined` prop as "reset to the default", but
+ // `MuxDataSdk={undefined}` is how consumers disable monitoring. Sync it here
+ // instead: passing the prop wins even when its value is `undefined`, and only
+ // omitting it falls back to the default SDK.
+ const sdk = 'MuxDataSdk' in props ? MuxDataSdk : muxDataDefaultProps.MuxDataSdk;
+ if (component.MuxDataSdk !== sdk) component.MuxDataSdk = sdk;
+
+ useSyncProps(component, rest, muxDataDefaultProps);
+
+ return null;
+}
+
+export namespace MuxData {
+ export type Props = MuxDataProps;
+}
diff --git a/packages/react/src/media/mux-video/media.tsx b/packages/react/src/media/mux-video/media.tsx
index 91afc913..89ebd5f2 100644
--- a/packages/react/src/media/mux-video/media.tsx
+++ b/packages/react/src/media/mux-video/media.tsx
@@ -1,11 +1,9 @@
'use client';
-import { GoogleCast } from '@videojs/media/dom/google-cast';
import type { HlsMediaProps } from '@videojs/media/dom/hls-js';
import { hlsMediaDefaultProps, StreamTypes } from '@videojs/media/dom/hls-js';
-import { addMediaComponent } from '@videojs/media/dom/media-host';
import type { MuxMediaProps } from '@videojs/media/dom/mux';
-import { MuxData, MuxMedia, muxMediaDefaultProps } from '@videojs/media/dom/mux';
+import { MuxMedia, muxMediaDefaultProps } from '@videojs/media/dom/mux';
import type { ReactNode, VideoHTMLAttributes } from 'react';
import { forwardRef, useCallback, useSyncExternalStore } from 'react';
import { useAttachMedia } from '../../utils/use-attach-media';
@@ -23,10 +21,7 @@ export interface MuxVideoProps
const muxVideoDefaultProps: HlsMediaProps & MuxMediaProps = { ...hlsMediaDefaultProps, ...muxMediaDefaultProps };
export const MuxVideo = forwardRef(function MuxVideo({ children, ...props }, ref) {
- const media = useMediaInstance(MuxMedia, (media) => {
- addMediaComponent(media, new MuxData({ playerSoftwareName: 'mux-video' }));
- addMediaComponent(media, new GoogleCast());
- });
+ const media = useMediaInstance(MuxMedia);
const attachRef = useAttachMedia(media);
const composedRef = useComposedRefs(attachRef, ref);
const htmlProps = useSyncProps(media, props, muxVideoDefaultProps);
diff --git a/packages/react/src/media/native-hls-video/media.tsx b/packages/react/src/media/native-hls-video/media.tsx
index ab946429..4033f0fe 100644
--- a/packages/react/src/media/native-hls-video/media.tsx
+++ b/packages/react/src/media/native-hls-video/media.tsx
@@ -1,7 +1,5 @@
'use client';
-import { GoogleCast } from '@videojs/media/dom/google-cast';
-import { addMediaComponent } from '@videojs/media/dom/media-host';
import type { NativeHlsMediaProps } from '@videojs/media/dom/native-hls';
import { NativeHlsMedia, nativeHlsMediaDefaultProps } from '@videojs/media/dom/native-hls';
import type { ReactNode, VideoHTMLAttributes } from 'react';
@@ -21,9 +19,7 @@ export const NativeHlsVideo = forwardRef(
{ children, ...props },
ref
) {
- const media = useMediaInstance(NativeHlsMedia, (media) => {
- addMediaComponent(media, new GoogleCast());
- });
+ const media = useMediaInstance(NativeHlsMedia);
const attachRef = useAttachMedia(media);
const composedRef = useComposedRefs(attachRef, ref);
const htmlProps = useSyncProps(media, props, nativeHlsMediaDefaultProps);
diff --git a/packages/react/src/media/tests/google-cast.test.tsx b/packages/react/src/media/tests/google-cast.test.tsx
new file mode 100644
index 00000000..34bc7e85
--- /dev/null
+++ b/packages/react/src/media/tests/google-cast.test.tsx
@@ -0,0 +1,64 @@
+import { render } from '@testing-library/react';
+import type { Media } from '@videojs/media';
+import { GoogleCast as GoogleCastComponent } from '@videojs/media/dom/google-cast';
+import { HlsJsMedia } from '@videojs/media/dom/hls-js';
+import { getMediaComponents } from '@videojs/media/dom/media-host';
+import { describe, expect, it } from 'vitest';
+import { createPlayerWrapper } from '../../testing/mocks';
+import { GoogleCast } from '../google-cast';
+
+function setup(media: Media | null = new HlsJsMedia()) {
+ const { value, Wrapper } = createPlayerWrapper();
+ value.media = media;
+ return { media, Wrapper };
+}
+
+describe('GoogleCast', () => {
+ it('registers a GoogleCast component with the media from context', () => {
+ const { media, Wrapper } = setup();
+
+ render(, { wrapper: Wrapper });
+
+ expect(getMediaComponents(media as HlsJsMedia).get(GoogleCastComponent)).toBeInstanceOf(GoogleCastComponent);
+ });
+
+ it('syncs props to the component', () => {
+ const { media, Wrapper } = setup();
+
+ render(, {
+ wrapper: Wrapper,
+ });
+
+ const component = getMediaComponents(media as HlsJsMedia).get(GoogleCastComponent)!;
+ expect(component.receiver).toBe('APP_ID');
+ expect(component.contentType).toBe('application/x-mpegURL');
+ expect(component.streamType).toBe('live');
+ });
+
+ it('resets a removed prop to its default', () => {
+ const { media, Wrapper } = setup();
+
+ const { rerender } = render(, { wrapper: Wrapper });
+ rerender();
+
+ expect(getMediaComponents(media as HlsJsMedia).get(GoogleCastComponent)!.receiver).toBeUndefined();
+ });
+
+ it('removes the component on unmount', () => {
+ const { media, Wrapper } = setup();
+
+ const { unmount } = render(, { wrapper: Wrapper });
+ unmount();
+
+ expect(getMediaComponents(media as HlsJsMedia).get(GoogleCastComponent)).toBeUndefined();
+ });
+
+ it('ignores media that is not a media host', () => {
+ const video = document.createElement('video') as unknown as Media;
+ const { Wrapper } = setup(video);
+
+ render(, { wrapper: Wrapper });
+
+ expect(getMediaComponents(video as any).get(GoogleCastComponent)).toBeUndefined();
+ });
+});
diff --git a/packages/react/src/media/tests/mux-data.test.tsx b/packages/react/src/media/tests/mux-data.test.tsx
new file mode 100644
index 00000000..40107a15
--- /dev/null
+++ b/packages/react/src/media/tests/mux-data.test.tsx
@@ -0,0 +1,92 @@
+import { render } from '@testing-library/react';
+import type { Media } from '@videojs/media';
+import { addMediaComponent, getMediaComponents } from '@videojs/media/dom/media-host';
+import { MuxData as MuxDataComponent, MuxMedia } from '@videojs/media/dom/mux';
+import { describe, expect, it, vi } from 'vitest';
+import { createPlayerWrapper } from '../../testing/mocks';
+import { MuxData } from '../mux-data';
+
+function setup() {
+ const media = new MuxMedia();
+ const { value, Wrapper } = createPlayerWrapper();
+ value.media = media as unknown as Media;
+ return { media, Wrapper };
+}
+
+describe('MuxData', () => {
+ it('registers a MuxData component with the media from context', () => {
+ const { media, Wrapper } = setup();
+
+ render(, { wrapper: Wrapper });
+
+ expect(getMediaComponents(media).get(MuxDataComponent)).toBeInstanceOf(MuxDataComponent);
+ });
+
+ it('syncs props to the component', () => {
+ const { media, Wrapper } = setup();
+
+ render(, { wrapper: Wrapper });
+
+ const component = getMediaComponents(media).get(MuxDataComponent)!;
+ expect(component.envKey).toBe('test-key');
+ expect(component.playerSoftwareName).toBe('mux-video');
+ expect(component.disableCookies).toBe(true);
+ });
+
+ it('disables monitoring when MuxDataSdk is explicitly undefined', () => {
+ const { media, Wrapper } = setup();
+ const MuxDataSdk = {
+ monitor: vi.fn(),
+ utils: { now: () => 0 },
+ } as unknown as NonNullable;
+
+ const { rerender } = render(, { wrapper: Wrapper });
+ const component = getMediaComponents(media).get(MuxDataComponent)!;
+ expect(component.MuxDataSdk).toBe(MuxDataSdk);
+
+ rerender();
+ expect(component.MuxDataSdk).toBeUndefined();
+
+ rerender();
+ expect(component.MuxDataSdk).toBeDefined();
+ });
+
+ it('resets a removed prop to its default', () => {
+ const { media, Wrapper } = setup();
+
+ const { rerender } = render(, { wrapper: Wrapper });
+ rerender();
+
+ expect(getMediaComponents(media).get(MuxDataComponent)!.disableCookies).toBe(false);
+ });
+
+ it('keeps the component alive when the media host is destroyed while mounted', () => {
+ const { media, Wrapper } = setup();
+ const destroy = vi.spyOn(MuxDataComponent.prototype, 'destroy');
+
+ render(, { wrapper: Wrapper });
+ const component = getMediaComponents(media).get(MuxDataComponent)!;
+
+ media.destroy();
+
+ // The host detaches and unregisters components it doesn't own; this one is
+ // owned by the still-mounted `MuxData` and follows the next media.
+ expect(destroy).not.toHaveBeenCalled();
+ expect(getMediaComponents(media).get(MuxDataComponent)).toBeUndefined();
+
+ const next = new MuxMedia();
+ addMediaComponent(next, component);
+ expect(getMediaComponents(next).get(MuxDataComponent)).toBe(component);
+
+ destroy.mockRestore();
+ });
+
+ it('removes the component on unmount', () => {
+ const { media, Wrapper } = setup();
+
+ const { unmount } = render(, { wrapper: Wrapper });
+ unmount();
+
+ expect(getMediaComponents(media).get(MuxDataComponent)).toBeUndefined();
+ });
+});
diff --git a/packages/react/src/media/tests/mux-video.test.tsx b/packages/react/src/media/tests/mux-video.test.tsx
index 4d5b2185..2d65ad90 100644
--- a/packages/react/src/media/tests/mux-video.test.tsx
+++ b/packages/react/src/media/tests/mux-video.test.tsx
@@ -1,36 +1,15 @@
import { render } from '@testing-library/react';
import { HlsJsMedia } from '@videojs/media/dom/hls-js';
-import { MuxData, MuxMedia } from '@videojs/media/dom/mux';
+import { MuxMedia } from '@videojs/media/dom/mux';
import { describe, expect, it, vi } from 'vitest';
import { MuxVideo } from '../mux-video';
describe('MuxVideo', () => {
- it('routes component config to the MuxData component', () => {
- const envKey = vi.spyOn(MuxData.prototype, 'envKey', 'set');
+ it('does not spread the config prop onto the element', () => {
+ const { container } = render();
- // `useSyncProps` writes `media.config` during render, before the mount
- // effect registers the components — `addMediaComponent` adopts the early value.
- const { container } = render();
-
- expect(envKey).toHaveBeenCalledWith('test-key');
// The config prop is consumed by the media, not spread onto the element.
expect(container.querySelector('video')!.hasAttribute('config')).toBe(false);
-
- envKey.mockRestore();
- });
-
- it('does not reinitialize mux data when the same config is re-rendered', () => {
- const reinit = vi.spyOn(MuxData.prototype, 'envKey', 'set');
-
- const { rerender } = render();
- rerender();
-
- // The setter runs per assignment but dedupes same values internally;
- // assert it was only handed the same value.
- expect(reinit).toHaveBeenCalledWith('test-key');
- expect(reinit.mock.calls.every(([value]) => value === 'test-key')).toBe(true);
-
- reinit.mockRestore();
});
it('derives the media src from the source prop', () => {
diff --git a/packages/react/src/utils/use-media-component.ts b/packages/react/src/utils/use-media-component.ts
new file mode 100644
index 00000000..79ec3950
--- /dev/null
+++ b/packages/react/src/utils/use-media-component.ts
@@ -0,0 +1,33 @@
+'use client';
+
+import type { HTMLMediaTargetLike, MediaComponent } from '@videojs/media/dom/media-host';
+import { addMediaComponent, HTMLMediaElementHost } from '@videojs/media/dom/media-host';
+import { useEffect, useState } from 'react';
+
+import { useMedia } from '../player/context';
+import { useDestroy } from './use-destroy';
+
+/**
+ * Create a media component (e.g. `GoogleCast`, `MuxData`) and register it
+ * with the media provided by the surrounding player context.
+ *
+ * Instantiates the component class once, registers it when a media host is
+ * available, follows the media when it changes, and destroys the component
+ * on unmount. Media that is not a media host (e.g. a plain `