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}> + + + 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`Video poster` : ''} 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 `