diff --git a/apps/sandbox/templates/html-mux-audio/main.ts b/apps/sandbox/templates/html-mux-audio/main.ts
index 1120e6be..17e6fca5 100644
--- a/apps/sandbox/templates/html-mux-audio/main.ts
+++ b/apps/sandbox/templates/html-mux-audio/main.ts
@@ -28,7 +28,7 @@ async function render() {
diff --git a/apps/sandbox/templates/html-mux-video/main.ts b/apps/sandbox/templates/html-mux-video/main.ts
index 317b3fc0..ef28b9e5 100644
--- a/apps/sandbox/templates/html-mux-video/main.ts
+++ b/apps/sandbox/templates/html-mux-video/main.ts
@@ -32,7 +32,7 @@ async function render() {
document.getElementById('root')!.innerHTML = html`
<${playerTag}>
<${tag} class="aspect-video max-w-4xl mx-auto">
-
+
${renderStoryboard(storyboard)}
${poster ? html`
` : ''}
diff --git a/apps/sandbox/templates/react-mux-audio/main.tsx b/apps/sandbox/templates/react-mux-audio/main.tsx
index e80d2caf..405ded0e 100644
--- a/apps/sandbox/templates/react-mux-audio/main.tsx
+++ b/apps/sandbox/templates/react-mux-audio/main.tsx
@@ -31,7 +31,6 @@ function App() {
{
readonly type: string;
readonly timeStamp: number;
@@ -14,17 +18,28 @@ export interface EventTargetLike;
- watchAvailability(callback: (available: boolean) => void): Promise;
- cancelWatchAvailability(id?: number): Promise;
-}
-
export function TypedEventTarget() {
return EventTarget as unknown as { new (): EventTargetLike };
}
+// ----------------------------------------
+// Shared value types
+// ----------------------------------------
+
+export type MediaFeatureAvailability = 'available' | 'unavailable' | 'unsupported';
+
+// ----------------------------------------
+// Controls
+// ----------------------------------------
+
+export interface MediaControlsCapability {
+ controls: boolean;
+}
+
+// ----------------------------------------
+// Playback
+// ----------------------------------------
+
export interface MediaPlaybackEvents {
play: EventLike;
playing: EventLike;
@@ -35,6 +50,18 @@ export interface MediaPlaybackCapability {
play(): Promise;
}
+// ----------------------------------------
+// Autoplay
+// ----------------------------------------
+
+export interface MediaAutoplayCapability {
+ autoplay: boolean;
+}
+
+// ----------------------------------------
+// Pause
+// ----------------------------------------
+
export interface MediaPauseEvents {
pause: EventLike;
ended: EventLike;
@@ -46,6 +73,10 @@ export interface MediaPauseCapability {
readonly ended: boolean;
}
+// ----------------------------------------
+// Seek
+// ----------------------------------------
+
export interface MediaSeekEvents {
timeupdate: EventLike;
durationchange: EventLike;
@@ -56,24 +87,16 @@ export interface MediaSeekEvents {
export interface MediaSeekCapability {
currentTime: number;
+ loop: boolean;
readonly duration: number;
readonly seeking: boolean;
}
-export interface MediaSourceEvents {
- loadstart: EventLike;
- emptied: EventLike;
- canplay: EventLike;
- canplaythrough: EventLike;
- loadeddata: EventLike;
-}
+// ----------------------------------------
+// Source
+// ----------------------------------------
-export interface MediaSourceCapability {
- src: string;
- readonly currentSrc: string;
- readonly readyState: MediaReadyStateValue | number;
- load(): void;
-}
+export type MediaPreloadType = '' | 'none' | 'metadata' | 'auto';
const MediaReadyState = {
HAVE_NOTHING: 0,
@@ -85,7 +108,33 @@ const MediaReadyState = {
export type MediaReadyStateValue = (typeof MediaReadyState)[keyof typeof MediaReadyState];
-export type MediaFeatureAvailability = 'available' | 'unavailable' | 'unsupported';
+export interface MediaSourceEvents {
+ loadstart: EventLike;
+ emptied: EventLike;
+ canplay: EventLike;
+ canplaythrough: EventLike;
+ loadeddata: EventLike;
+ abort: EventLike;
+ stalled: EventLike;
+ suspend: EventLike;
+}
+
+/** Result of {@link MediaSourceCapability.canPlayType}. */
+export type CanPlayTypeResult = '' | 'maybe' | 'probably';
+
+export interface MediaSourceCapability {
+ src: string;
+ readonly currentSrc: string;
+ readonly readyState: MediaReadyStateValue | number;
+ preload: MediaPreloadType;
+ crossOrigin: string | null;
+ load(): Promise | void;
+ canPlayType(type: string): CanPlayTypeResult;
+}
+
+// ----------------------------------------
+// Volume
+// ----------------------------------------
export interface MediaVolumeEvents {
volumechange: EventLike;
@@ -94,19 +143,25 @@ export interface MediaVolumeEvents {
export interface MediaVolumeCapability {
volume: number;
muted: boolean;
+ defaultMuted: boolean;
}
+// ----------------------------------------
+// Playback rate
+// ----------------------------------------
+
export interface MediaPlaybackRateEvents {
ratechange: EventLike;
}
export interface MediaPlaybackRateCapability {
playbackRate: number;
+ defaultPlaybackRate: number;
}
-export interface MediaBufferEvents {
- progress: EventLike;
-}
+// ----------------------------------------
+// Buffer
+// ----------------------------------------
export interface TimeRangeLike {
readonly length: number;
@@ -114,24 +169,44 @@ export interface TimeRangeLike {
end(index: number): number;
}
+export interface MediaBufferEvents {
+ progress: EventLike;
+}
+
export interface MediaBufferCapability {
readonly buffered: TimeRangeLike;
readonly seekable: TimeRangeLike;
}
-export interface MediaErrorEvents {
- error: EventLike;
+// ----------------------------------------
+// Played
+// ----------------------------------------
+
+export interface MediaPlayedCapability {
+ readonly played: TimeRangeLike;
}
+// ----------------------------------------
+// Error
+// ----------------------------------------
+
export interface ErrorLike {
readonly code: number;
readonly message: string;
}
+export interface MediaErrorEvents {
+ error: EventLike;
+}
+
export interface MediaErrorCapability {
readonly error: ErrorLike | null;
}
+// ----------------------------------------
+// Text tracks
+// ----------------------------------------
+
export interface TextCueLike {
readonly startTime: number;
readonly endTime: number;
@@ -144,6 +219,13 @@ export interface TextCueListLike {
getCueById?(id: string): TextCueLike | null;
}
+/**
+ * The kind of text track.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/TextTrack/kind
+ */
+export type TextTrackKind = 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata';
+
export interface TextTrackLike {
readonly kind: string;
readonly label: string;
@@ -158,11 +240,10 @@ export interface TextTrackLike {
export interface TextTrackListEvents {
addtrack: EventLike;
removetrack: EventLike;
- changetrack: EventLike;
- trackmodechange: EventLike;
+ change: EventLike;
}
-export interface TextTrackListLike {
+export interface TextTrackListLike extends EventTargetLike {
readonly length: number;
readonly [index: number]: TextTrackLike;
[Symbol.iterator](): Iterator;
@@ -171,27 +252,38 @@ export interface TextTrackListLike {
export interface MediaTextTrackCapability {
readonly textTracks: TextTrackListLike;
+ addTextTrack(kind: TextTrackKind, label?: string, language?: string): TextTrackLike;
}
+// ----------------------------------------
+// Fullscreen
+// ----------------------------------------
+
export interface MediaFullscreenCapability {
readonly isFullscreen: boolean;
requestFullscreen(): Promise;
exitFullscreen(): Promise;
}
+// ----------------------------------------
+// Picture-in-picture
+// ----------------------------------------
+
+export interface MediaPictureInPictureEvents {
+ enterpictureinpicture: EventLike;
+ leavepictureinpicture: EventLike;
+}
+
export interface MediaPictureInPictureCapability {
readonly isPictureInPicture: boolean;
+ disablePictureInPicture: boolean;
requestPictureInPicture(): Promise;
exitPictureInPicture(): Promise;
}
-export interface MediaRemotePlaybackCapability {
- readonly remote: RemotePlaybackLike;
-}
-
-export interface MediaStreamTypeEvents {
- streamtypechange: EventLike;
-}
+// ----------------------------------------
+// Stream type
+// ----------------------------------------
/**
* Canonical values for {@link MediaStreamType}.
@@ -211,8 +303,12 @@ export const MediaStreamTypes = {
export type MediaStreamType = (typeof MediaStreamTypes)[keyof typeof MediaStreamTypes];
+export interface MediaStreamTypeEvents {
+ streamtypechange: EventLike;
+}
+
export interface MediaStreamTypeCapability {
- readonly streamType: MediaStreamType;
+ streamType: MediaStreamType;
}
export interface MediaLiveEvents {
@@ -239,15 +335,81 @@ export interface MediaLiveCapability {
readonly targetLiveWindow: number;
}
-interface MediaEvents extends MediaPlaybackEvents {}
+// ----------------------------------------
+// Remote playback
+// ----------------------------------------
-export interface Media extends MediaPlaybackCapability, EventTargetLike {
- readonly engine?: unknown;
- readonly target?: unknown;
+export interface RemotePlaybackEvents {
+ connecting: EventLike;
+ connect: EventLike;
+ disconnect: EventLike;
}
-export interface VideoEvents
- extends MediaPlaybackEvents,
+export interface RemotePlaybackLike extends EventTargetLike {
+ readonly state: 'connecting' | 'connected' | 'disconnected';
+ prompt(): Promise;
+ watchAvailability(callback: (available: boolean) => void): Promise;
+ cancelWatchAvailability(id?: number): Promise;
+}
+
+export interface MediaRemotePlaybackCapability {
+ readonly remote: RemotePlaybackLike;
+ disableRemotePlayback: boolean;
+}
+
+// ----------------------------------------
+// Plays inline (video-only)
+// ----------------------------------------
+
+export interface MediaPlaysInlineCapability {
+ playsInline: boolean;
+}
+
+// ----------------------------------------
+// Poster (video-only)
+// ----------------------------------------
+
+export interface MediaPosterCapability {
+ poster: string;
+}
+
+// ----------------------------------------
+// Video dimensions (video-only)
+// ----------------------------------------
+
+export interface MediaVideoDimensionsEvents {
+ resize: EventLike;
+}
+
+export interface MediaVideoDimensionsCapability {
+ readonly videoWidth: number;
+ readonly videoHeight: number;
+}
+
+// ----------------------------------------
+// Config
+// ----------------------------------------
+
+export interface MediaConfigCapability {
+ config: Record;
+}
+
+// ----------------------------------------
+// Base Media
+// ----------------------------------------
+
+export interface MediaEvents extends MediaPlaybackEvents {}
+
+export interface Media
+ extends MediaPlaybackCapability,
+ EventTargetLike {}
+
+// ----------------------------------------
+// Composed shapes
+// ----------------------------------------
+
+export interface MediaFullEvents
+ extends MediaEvents,
MediaPauseEvents,
MediaSeekEvents,
MediaSourceEvents,
@@ -255,36 +417,47 @@ export interface VideoEvents
MediaPlaybackRateEvents,
MediaBufferEvents,
MediaErrorEvents,
- TextTrackListEvents {}
+ TextTrackListEvents,
+ MediaStreamTypeEvents,
+ MediaLiveEvents {}
-export interface Video
- extends MediaPlaybackCapability,
+export interface MediaFull
+ extends Media,
MediaPauseCapability,
MediaSeekCapability,
MediaSourceCapability,
MediaVolumeCapability,
MediaPlaybackRateCapability,
MediaBufferCapability,
+ MediaPlayedCapability,
MediaErrorCapability,
MediaTextTrackCapability,
+ MediaStreamTypeCapability,
+ MediaLiveCapability,
+ MediaRemotePlaybackCapability,
+ MediaControlsCapability,
+ MediaAutoplayCapability,
+ MediaConfigCapability {}
+
+export interface VideoEvents extends MediaFullEvents, MediaPictureInPictureEvents, MediaVideoDimensionsEvents {}
+
+export interface Video
+ extends MediaFull,
+ MediaPlaysInlineCapability,
+ MediaPosterCapability,
MediaFullscreenCapability,
MediaPictureInPictureCapability,
- EventTargetLike {
- readonly engine?: unknown;
- readonly target?: unknown;
-}
+ MediaVideoDimensionsCapability {}
-export interface AudioEvents
- extends MediaPlaybackEvents,
- MediaPauseEvents,
- MediaSeekEvents,
- MediaSourceEvents,
- MediaVolumeEvents,
- MediaPlaybackRateEvents,
- MediaBufferEvents,
- MediaErrorEvents {}
+export interface AudioEvents extends MediaFullEvents {}
-export interface Audio
+export interface Audio extends MediaFull {}
+
+// ----------------------------------------
+// Target shapes
+// ----------------------------------------
+
+export interface MediaTargetLike
extends MediaPlaybackCapability,
MediaPauseCapability,
MediaSeekCapability,
@@ -292,15 +465,30 @@ export interface Audio
MediaVolumeCapability,
MediaPlaybackRateCapability,
MediaBufferCapability,
+ MediaPlayedCapability,
MediaErrorCapability,
- EventTargetLike {
- readonly engine?: unknown;
- readonly target?: unknown;
+ MediaTextTrackCapability,
+ MediaRemotePlaybackCapability,
+ MediaControlsCapability,
+ MediaAutoplayCapability,
+ Partial,
+ Partial,
+ Partial {
+ title: string;
+}
+
+export interface VideoTargetLike
+ extends MediaTargetLike,
+ MediaPosterCapability,
+ MediaPlaysInlineCapability,
+ MediaVideoDimensionsCapability {
+ disablePictureInPicture: boolean;
+ requestPictureInPicture(): Promise;
+ requestFullscreen(): Promise;
}
export interface MediaEngineHost {
readonly engine: Engine | null;
- readonly target: Target | null;
attach?(target: Target): void;
detach?(): void;
destroy(): void;
diff --git a/packages/core/src/dom/media/audio-host.ts b/packages/core/src/dom/media/audio-host.ts
index 837c1773..436a10d9 100644
--- a/packages/core/src/dom/media/audio-host.ts
+++ b/packages/core/src/dom/media/audio-host.ts
@@ -1,4 +1,4 @@
import type { Audio, AudioEvents } from '../../core/media/types';
-import { HTMLMediaElementHost } from './media-host';
+import { HTMLMediaElementHost, type HTMLMediaTargetLike } from './media-host';
-export class HTMLAudioElementHost extends HTMLMediaElementHost implements Audio {}
+export class HTMLAudioElementHost extends HTMLMediaElementHost implements Audio {}
diff --git a/packages/core/src/dom/media/constants.ts b/packages/core/src/dom/media/constants.ts
new file mode 100644
index 00000000..56acac56
--- /dev/null
+++ b/packages/core/src/dom/media/constants.ts
@@ -0,0 +1,17 @@
+import type { RemotePlaybackLike, TextTrackListLike, TimeRangeLike } from '../../core/media/types';
+
+export const EMPTY_TIME_RANGES: TimeRangeLike = Object.freeze({
+ length: 0,
+ start: () => 0,
+ end: () => 0,
+});
+
+export const EMPTY_TEXT_TRACKS: TextTrackListLike = Object.assign(new EventTarget(), {
+ length: 0,
+ *[Symbol.iterator]() {},
+ getTrackById: () => null,
+}) as unknown as TextTrackListLike;
+
+export const EMPTY_REMOTE = new EventTarget() as unknown as RemotePlaybackLike;
+
+export const EMPTY_CONFIG: Record = Object.freeze({});
diff --git a/packages/core/src/dom/media/custom-media-element/index.ts b/packages/core/src/dom/media/custom-media-element/index.ts
index 865c5521..d25ab0fa 100644
--- a/packages/core/src/dom/media/custom-media-element/index.ts
+++ b/packages/core/src/dom/media/custom-media-element/index.ts
@@ -81,17 +81,18 @@ function getCommonTemplateHTML(tag: string) {
const excludedProperties = ['attach', 'detach', 'destroy'];
-interface MediaHost extends EventTarget {
- readonly target: EventTarget | null;
+export interface MediaHost extends EventTarget {
attach(target: EventTarget | null): void;
detach(): void;
destroy(): void;
- /** Index signature for dynamic property forwarding. */
+ /** Index signature for dynamic property forwarding (includes the host's protected `target`). */
[key: string]: any;
}
-type CustomMediaConstructor> = Constructor> & {
- properties: Record;
+type CustomMediaConstructor> = Constructor<
+ HTMLElement & InstanceType & { readonly host: InstanceType }
+> & {
+ properties: Record;
getTemplateHTML: (attrs: Record) => string;
shadowRootOptions: ShadowRootInit;
readonly observedAttributes: string[];
@@ -119,9 +120,10 @@ export function CustomMediaElement>(
loading: { type: String },
loop: { type: Boolean },
playsInline: { type: Boolean },
- poster: { type: String },
- preload: { type: String },
- src: { type: String },
+ poster: { type: String, empty: '' },
+ preload: { type: String, empty: null },
+ src: { type: String, empty: '' },
+ streamType: { type: String, attribute: 'stream-type', empty: 'unknown' },
};
static get observedAttributes() {
@@ -137,9 +139,19 @@ export function CustomMediaElement>(
if (isDefined) return;
isDefined = true;
+ const properties = ctor.properties as Record;
+
for (let proto = MediaHost.prototype; proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
for (const prop of Object.getOwnPropertyNames(proto)) {
if (prop in CustomMedia.prototype || excludedProperties.includes(prop)) continue;
+ // Defer to the explicit `ctor.properties` loop when its attribute
+ // mapping diverges from `kebabCase(prop)`. Covers multi-word camelCase
+ // props (`playsInline` → `'playsinline'`) and explicit overrides
+ // (`defaultMuted` → `attribute: 'muted'`). Single-word props in
+ // `properties` (like `loop`, `preload`) keep their legacy proto-walk
+ // path so the mediaHost still receives the setter call.
+ const propConfig = properties[prop];
+ if (propConfig && (propConfig.attribute ?? prop.toLowerCase()) !== kebabCase(prop)) continue;
const descriptor = Object.getOwnPropertyDescriptor(proto, prop);
if (!descriptor) continue;
@@ -182,7 +194,6 @@ export function CustomMediaElement>(
}
}
- const properties = ctor.properties as Record;
for (const [prop, { type, attribute }] of Object.entries(properties)) {
if (prop in CustomMedia.prototype) continue;
@@ -245,6 +256,10 @@ export function CustomMediaElement>(
this.#mediaHost.attach(target);
}
+ get host(): MediaHost {
+ return this.#mediaHost;
+ }
+
get target(): HTMLVideoElement | HTMLAudioElement | null {
return (
this.querySelector(':scope > [slot=media]') ??
@@ -255,9 +270,12 @@ export function CustomMediaElement>(
}
disconnectedCallback(): void {
- if (!this.hasAttribute('keep-alive')) {
- this.#mediaHost.destroy();
- }
+ if (this.hasAttribute('keep-alive')) return;
+ // Defer so a synchronous reparent (remove + insert) doesn't tear down
+ // the host and its registered components.
+ queueMicrotask(() => {
+ if (!this.isConnected) this.#mediaHost.destroy();
+ });
}
addEventListener(
@@ -291,8 +309,14 @@ export function CustomMediaElement>(
if (prop) {
if (oldValue !== newValue) {
const valueType = typeof this.#mediaHost[prop];
+ const propConfig = (this.constructor as CustomMediaConstructor).properties[prop];
+ const emptyValue = propConfig && 'empty' in propConfig ? propConfig.empty : '';
this.#mediaHost[prop] =
- valueType === 'boolean' ? newValue !== null : valueType === 'number' ? Number(newValue) : (newValue ?? '');
+ valueType === 'boolean'
+ ? newValue !== null
+ : valueType === 'number'
+ ? Number(newValue)
+ : (newValue ?? emptyValue);
}
return;
}
diff --git a/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts b/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts
index 2b148c57..53166fb7 100644
--- a/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts
+++ b/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts
@@ -103,12 +103,17 @@ function create(def: { Ctor: new () => any; tag: string }) {
}
class TrackingVideoHost extends HTMLVideoElementHost {
- calls: string[] = [];
+ #calls: string[] = [];
#src = '';
#volume = 1;
#muted = false;
#currentTime = 0;
#playbackRate = 1;
+ #preload: '' | 'none' | 'metadata' | 'auto' | null = 'metadata';
+
+ get calls() {
+ return this.#calls;
+ }
get src() {
return this.#src;
@@ -155,6 +160,16 @@ class TrackingVideoHost extends HTMLVideoElementHost {
this.#playbackRate = value;
}
+ override get preload() {
+ return this.#preload ?? 'metadata';
+ }
+
+ override set preload(value: '' | 'none' | 'metadata' | 'auto') {
+ const preload = value as '' | 'none' | 'metadata' | 'auto' | null;
+ this.calls.push(`set:preload:${preload}`);
+ this.#preload = preload;
+ }
+
destroy() {}
}
@@ -247,6 +262,7 @@ describe('CustomMediaElement', () => {
expect(observed).toContain('poster');
expect(observed).toContain('autopictureinpicture');
expect(observed).toContain('disablepictureinpicture');
+ expect(observed).toContain('stream-type');
});
it('includes standard attributes for audio elements', () => {
@@ -259,6 +275,7 @@ describe('CustomMediaElement', () => {
expect(observed).toContain('muted');
expect(observed).toContain('preload');
expect(observed).toContain('src');
+ expect(observed).toContain('stream-type');
});
});
@@ -279,6 +296,27 @@ describe('CustomMediaElement', () => {
});
});
+ describe('stream-type reflection', () => {
+ it('sets the host streamType from the stream-type attribute', () => {
+ const el = create(defineVideoElement());
+ el.setAttribute('stream-type', 'live');
+ expect(el.streamType).toBe('live');
+ });
+
+ it('reflects the streamType property to the stream-type attribute', () => {
+ const el = create(defineVideoElement());
+ el.streamType = 'live';
+ expect(el.getAttribute('stream-type')).toBe('live');
+ expect(el.streamType).toBe('live');
+ });
+
+ it('does not forward stream-type to the inner media element', () => {
+ const el = create(defineVideoElement());
+ el.setAttribute('stream-type', 'live');
+ expect(el.target!.hasAttribute('stream-type')).toBe(false);
+ });
+ });
+
describe('video attribute forwarding', () => {
it('forwards autoplay to the target video element', () => {
const el = create(defineVideoElement());
@@ -393,22 +431,22 @@ describe('CustomMediaElement', () => {
const el = create(defineVideoElement());
const target = el.target!;
- el.setAttribute('preload', 'metadata');
- expect(target.getAttribute('preload')).toBe('metadata');
+ el.setAttribute('crossorigin', 'anonymous');
+ expect(target.getAttribute('crossorigin')).toBe('anonymous');
- el.removeAttribute('preload');
- expect(target.hasAttribute('preload')).toBe(false);
+ el.removeAttribute('crossorigin');
+ expect(target.hasAttribute('crossorigin')).toBe(false);
});
it('updates forwarded attribute value when changed', () => {
const el = create(defineVideoElement());
const target = el.target!;
- el.setAttribute('preload', 'metadata');
- expect(target.getAttribute('preload')).toBe('metadata');
+ el.setAttribute('crossorigin', 'anonymous');
+ expect(target.getAttribute('crossorigin')).toBe('anonymous');
- el.setAttribute('preload', 'auto');
- expect(target.getAttribute('preload')).toBe('auto');
+ el.setAttribute('crossorigin', 'use-credentials');
+ expect(target.getAttribute('crossorigin')).toBe('use-credentials');
});
});
@@ -448,28 +486,27 @@ describe('CustomMediaElement', () => {
it('string property getter returns the attribute value', () => {
const el = create(defineVideoElement());
- el.setAttribute('preload', 'auto');
- expect(el.preload).toBe('auto');
+ el.setAttribute('controlslist', 'nodownload');
+ expect(el.controlsList).toBe('nodownload');
});
it('string property getter returns null when attribute is absent', () => {
const el = create(defineVideoElement());
- expect(el.preload).toBeNull();
expect(el.controlsList).toBeNull();
});
it('string property setter sets the attribute and forwards to target', () => {
const el = create(defineVideoElement());
- el.preload = 'metadata';
- expect(el.getAttribute('preload')).toBe('metadata');
- expect(el.target!.getAttribute('preload')).toBe('metadata');
+ el.controlsList = 'nodownload';
+ expect(el.getAttribute('controlslist')).toBe('nodownload');
+ expect(el.target!.getAttribute('controlslist')).toBe('nodownload');
});
it('removing attribute resets string property getter to null', () => {
const el = create(defineVideoElement());
- el.preload = 'metadata';
- el.removeAttribute('preload');
- expect(el.preload).toBeNull();
+ el.controlsList = 'nodownload';
+ el.removeAttribute('controlslist');
+ expect(el.controlsList).toBeNull();
});
it('property accessors work for all non-MediaHost video attributes', () => {
@@ -487,9 +524,9 @@ describe('CustomMediaElement', () => {
expect(el.playsInline).toBe(true);
expect(el.hasAttribute('playsinline')).toBe(true);
- el.preload = 'metadata';
- expect(el.preload).toBe('metadata');
- expect(el.getAttribute('preload')).toBe('metadata');
+ el.controlsList = 'nodownload';
+ expect(el.controlsList).toBe('nodownload');
+ expect(el.getAttribute('controlslist')).toBe('nodownload');
el.crossOrigin = 'anonymous';
expect(el.crossOrigin).toBe('anonymous');
@@ -645,19 +682,34 @@ describe('CustomMediaElement', () => {
});
describe('disconnectedCallback', () => {
- it('calls destroy on the MediaHost when disconnected', () => {
+ it('calls destroy on the MediaHost when disconnected', async () => {
const el = create(defineVideoElement());
expect(el.destroyed).toBe(false);
el.remove();
+ // Destroy is deferred a microtask to allow synchronous reparenting.
+ await Promise.resolve();
expect(el.destroyed).toBe(true);
});
- it('does not destroy when keep-alive attribute is set', () => {
+ it('does not destroy when keep-alive attribute is set', async () => {
const el = create(defineVideoElement());
el.setAttribute('keep-alive', '');
el.remove();
+ await Promise.resolve();
+ expect(el.destroyed).toBe(false);
+ });
+
+ it('does not destroy when synchronously moved to a new parent', async () => {
+ const el = create(defineVideoElement());
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ // Moving fires disconnectedCallback + connectedCallback synchronously.
+ container.appendChild(el);
+ await Promise.resolve();
+
expect(el.destroyed).toBe(false);
});
});
@@ -699,6 +751,15 @@ describe('CustomMediaElement', () => {
expect(el.src).toBe('https://example.com/video.mp4');
});
+ it('preload setter delegates through the MediaHost', () => {
+ const el = create(defineTrackingVideoElement());
+ el.preload = 'metadata';
+
+ expect(el.getAttribute('preload')).toBe('metadata');
+ expect(el.preload).toBe('metadata');
+ expect(el.calls).toContain('set:preload:metadata');
+ });
+
it('number setter delegates directly to the MediaHost', () => {
const el = create(defineTrackingVideoElement());
el.volume = 0.5;
diff --git a/packages/core/src/dom/media/dash/index.ts b/packages/core/src/dom/media/dash/index.ts
index 68dfac5e..610722fa 100644
--- a/packages/core/src/dom/media/dash/index.ts
+++ b/packages/core/src/dom/media/dash/index.ts
@@ -23,19 +23,6 @@ export class DashMedia
this.#engine.initialize(undefined, undefined, false);
}
- get engine() {
- return this.#engine;
- }
-
- get src() {
- return this.#src;
- }
-
- set src(src) {
- this.#src = src;
- this.#engine.attachSource(src);
- }
-
attach(target: HTMLVideoElement) {
super.attach(target);
this.#engine.attachView(target);
@@ -50,5 +37,19 @@ export class DashMedia
destroy() {
this.detach();
this.#engine.destroy();
+ super.destroy();
+ }
+
+ get engine() {
+ return this.#engine;
+ }
+
+ get src() {
+ return this.#src;
+ }
+
+ set src(src) {
+ this.#src = src;
+ this.#engine.attachSource(src);
}
}
diff --git a/packages/core/src/dom/media/dash/tests/dash-media.test.ts b/packages/core/src/dom/media/dash/tests/dash-media.test.ts
index 19c54f4e..f1d9fcf9 100644
--- a/packages/core/src/dom/media/dash/tests/dash-media.test.ts
+++ b/packages/core/src/dom/media/dash/tests/dash-media.test.ts
@@ -46,14 +46,14 @@ describe('DashMedia', () => {
expect(playHandler).not.toHaveBeenCalled();
});
- it('nullifies the target reference', () => {
+ it('detaches the dash view from the target on destroy', () => {
const { media } = setup();
-
- expect(media.target).not.toBeNull();
+ const attachView = media.engine!.attachView as ReturnType;
+ attachView.mockClear();
media.destroy();
- expect(media.target).toBeNull();
+ expect(attachView).toHaveBeenCalledWith(null);
});
});
});
diff --git a/packages/core/src/dom/media/google-cast/google-cast-provider.ts b/packages/core/src/dom/media/google-cast/google-cast-provider.ts
index ba0e69bd..512a1695 100644
--- a/packages/core/src/dom/media/google-cast/google-cast-provider.ts
+++ b/packages/core/src/dom/media/google-cast/google-cast-provider.ts
@@ -1,71 +1,44 @@
import { isCaptionOrSubtitleTrack } from '@videojs/utils/dom';
-
-import type { RemotePlaybackState } from './remote-playback';
-import type { GoogleCastMediaElement } from './types';
+import type { HTMLMediaTargetLike } from '../media-host';
+import type { GoogleCastProps } from './index';
+import { castFramework, ensureCastFramework, googleCastInstances } from './registry';
+import { RemotePlayback, type RemotePlaybackHooks } from './remote-playback';
import {
- castContext,
currentMedia,
currentSession,
editTracksInfo,
+ getCastContext,
getMediaStatus,
getPlaylistSegmentFormat,
- IterableWeakSet,
+ InvalidStateError,
isHls,
NotSupportedError,
- onCastApiAvailable,
setCastOptions,
setPlaybackRate,
} from './utils';
type RemotePlayerListener = (event?: cast.framework.RemotePlayerChangedEvent) => void;
-export type GoogleCastProviderHooks = {
- setState: (next: RemotePlaybackState) => void;
- setAvailable: (available: boolean) => void;
-};
-
-export type LocalPlayer = {
- duration: () => number;
- currentTime: () => number;
- paused: () => boolean;
- muted: () => boolean;
- pause: () => void;
-};
-
-const providerInstances = new IterableWeakSet();
-const castElementRef = new WeakSet();
-
-let cf: typeof cast.framework | undefined;
-
-onCastApiAvailable(() => {
- if (!globalThis.chrome?.cast?.isAvailable) {
- console.debug('chrome.cast.isAvailable', globalThis.chrome?.cast?.isAvailable);
- return;
- }
-
- if (!cf) {
- cf = cast.framework;
-
- castContext()!.addEventListener(cf.CastContextEventType.CAST_STATE_CHANGED, () => {
- providerInstances.forEach((p) => p.onCastStateChanged());
- });
-
- castContext()!.addEventListener(cf.CastContextEventType.SESSION_STATE_CHANGED, () => {
- providerInstances.forEach((p) => p.onSessionStateChanged());
- });
-
- providerInstances.forEach((p) => p.init());
- }
-});
+type GoogleCastConfig = GoogleCastProps;
+/**
+ * Cast provider + lifecycle. Created by the {@link GoogleCast} component and
+ * installed as the host's `targetOverride` while a cast session is connected,
+ * so its getters/setters route through the cast receiver; when disconnected the
+ * host falls through to the attached target. Also owns the cast framework
+ * integration, the `RemotePlayback` instance exposed via
+ * {@link GoogleCastProvider#remote}, and dispatches media events on the attached
+ * target (forwarded by the host) while casting.
+ */
export class GoogleCastProvider {
- readonly media: GoogleCastMediaElement;
- seeking = false;
-
- #hooks: Partial = {};
+ target: HTMLMediaTargetLike | null = null;
+ #googleCast: GoogleCastConfig;
+ #hooks: Partial = {};
+ #remotePlayback: RemotePlayback;
#isInit = false;
- #local: LocalPlayer;
- #remote!: cast.framework.RemotePlayer;
+ #isCasting = false;
+ #seeking = false;
+ #remotePlayer!: cast.framework.RemotePlayer;
#remoteListeners!: Record;
#listenersAttached = false;
#playbackRate = 1;
@@ -73,47 +46,71 @@ export class GoogleCastProvider {
#onTextTrackChange = () => this.#updateRemoteTextTrack();
#onMediaUpdate = () => this.#checkPlaybackRate();
- constructor(media: GoogleCastMediaElement, local: LocalPlayer) {
- this.media = media;
- this.#local = local;
- providerInstances.add(this);
- this.init();
+ constructor(googleCast: GoogleCastConfig) {
+ this.#googleCast = googleCast;
+ this.#remotePlayback = new RemotePlayback(this);
+ googleCastInstances.add(this);
+ this.onCastFrameworkAvailable();
}
- bindHooks(hooks: Partial) {
+ get remote() {
+ if (this.target && !this.target.disableRemotePlayback) {
+ ensureCastFramework();
+ }
+ return this.#remotePlayback;
+ }
+
+ attach(target: HTMLMediaTargetLike) {
+ this.target = target;
+ target.textTracks.addEventListener('change', this.#onTextTrackChange);
+ }
+
+ detach() {
+ this.target?.textTracks.removeEventListener('change', this.#onTextTrackChange);
+ this.target = null;
+ }
+
+ destroy() {
+ this.detach();
+ googleCastInstances.delete(this);
+ currentMedia()?.removeUpdateListener(this.#onMediaUpdate);
+ this.#detachRemoteListeners();
+ this.#isCasting = false;
+ this.#isInit = false;
+ }
+
+ /** @internal Wires up callbacks pushed from {@link RemotePlayback}; not part of the public surface. */
+ bindHooks(hooks: Partial) {
Object.assign(this.#hooks, hooks);
}
- get isCasting() {
- return castElementRef.has(this.media);
- }
-
- set isCasting(value: boolean) {
- if (value) castElementRef.add(this.media);
- else castElementRef.delete(this.media);
- }
-
hasDevicesAvailable() {
- const state = castContext()?.getCastState();
+ const state = getCastContext()?.getCastState();
return !!state && state !== cast.framework.CastState.NO_DEVICES_AVAILABLE;
}
async requestCastSession() {
+ if (this.target?.disableRemotePlayback) {
+ throw new InvalidStateError('disableRemotePlayback attribute is present.');
+ }
+
+ await ensureCastFramework();
+
if (!this.#isCastApiAvailable()) {
throw new NotSupportedError('The RemotePlayback API is disabled on this platform.');
}
- const willDisconnect = this.isCasting;
- this.isCasting = true;
+ const willDisconnect = this.#isCasting;
+ this.#isCasting = true;
this.#applyCastOptions();
this.#attachRemoteListeners();
try {
- await castContext()!.requestSession();
+ await getCastContext()!.requestSession();
} catch (err) {
if (!willDisconnect) {
- this.isCasting = false;
+ this.#isCasting = false;
}
if (err === 'cancel') {
@@ -123,9 +120,9 @@ export class GoogleCastProvider {
throw new Error(err as string);
}
- this.#localPaused = this.#local.paused();
- this.#local.pause();
- this.muted = this.#local.muted();
+ this.#localPaused = this.target?.paused ?? true;
+ this.target?.pause();
+ this.muted = this.target?.muted ?? false;
try {
await this.load();
@@ -135,15 +132,23 @@ export class GoogleCastProvider {
}
async load() {
- if (!this.media.castSrc) {
+ if (!this.#isCasting) {
+ await this.target?.load();
+ return;
+ }
+
+ if (!this.#googleCast.src) {
// TODO: handle unloading the media?
return;
}
- const mediaInfo = new chrome.cast.media.MediaInfo(this.media.castSrc, this.media.castContentType ?? '');
- mediaInfo.customData = (this.media.castCustomData as object) ?? null;
+ const mediaInfo = new chrome.cast.media.MediaInfo(this.#googleCast.src, this.#googleCast.contentType ?? '');
+ mediaInfo.customData = this.#googleCast.customData ?? null;
- const subtitles = [...this.media.querySelectorAll('track')].filter((el) => el.src && isCaptionOrSubtitleTrack(el));
+ const { target } = this;
+ const subtitles = [...(target?.querySelectorAll('track') ?? [])].filter(
+ (el) => el.src && isCaptionOrSubtitleTrack(el)
+ );
const { Track, TrackType, TextTrackType } = chrome.cast.media;
const activeTrackIds: number[] = [];
@@ -164,16 +169,17 @@ export class GoogleCastProvider {
}
mediaInfo.streamType =
- this.media.castStreamType === 'live' ? chrome.cast.media.StreamType.LIVE : chrome.cast.media.StreamType.BUFFERED;
+ this.#googleCast.streamType === 'live'
+ ? chrome.cast.media.StreamType.LIVE
+ : chrome.cast.media.StreamType.BUFFERED;
mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata();
- mediaInfo.metadata.title = this.media.title;
- mediaInfo.metadata.images = [new chrome.cast.Image(this.media.poster)];
+ mediaInfo.metadata.images = [new chrome.cast.Image((target as HTMLVideoElement | null)?.poster ?? '')];
- if (await isHls(this.media.castSrc)) {
+ if (await isHls(this.#googleCast.src)) {
mediaInfo.contentType ||= 'application/x-mpegURL';
- const fmt = (await getPlaylistSegmentFormat(this.media.castSrc)) ?? '';
+ const fmt = (await getPlaylistSegmentFormat(this.#googleCast.src)) ?? '';
const { HlsSegmentFormat: HS, HlsVideoSegmentFormat: HVS } = chrome.cast.media;
if (fmt.includes('m4s') || fmt.includes('mp4')) {
@@ -186,72 +192,77 @@ export class GoogleCastProvider {
}
const request = new chrome.cast.media.LoadRequest(mediaInfo);
- request.currentTime = this.#local.currentTime();
+ // Use `super.currentTime` to read the local element's time even though our
+ // own `currentTime` getter is overridden to return the remote player's.
+ request.currentTime = this.target?.currentTime ?? 0;
request.autoplay = !this.#localPaused;
request.activeTrackIds = activeTrackIds;
await currentSession()?.loadMedia(request);
- this.media.dispatchEvent(new Event('volumechange'));
+ this.target?.dispatchEvent(new Event('volumechange'));
}
- // -- Native media surface (called by the mixin while isCasting is true) --
-
- async play() {
- // When a casted media ends, the media is unloaded and the player state is IDLE.
- if (!this.#remote.isMediaLoaded) {
- this.#localPaused = false;
- await this.load();
- return;
- }
- if (this.paused) {
- this.#remote.controller?.playOrPause();
- return new Promise((resolve) => {
- this.media.addEventListener('play', () => resolve(), { once: true });
- });
- }
- }
-
- pause() {
- if (!this.paused) {
- this.#remote.controller?.playOrPause();
- }
- }
-
- // isPaused is not true when the media has ended so add the ended check.
- // Fall back to the local element before remote media has loaded — e.g.
- // while the cast picker is open, `isCasting` is already true but the
- // RemotePlayer's `isPaused` still reports its default `false`, which
- // would otherwise leak through as `media.paused === false`.
get paused() {
- if (!this.#remote.isMediaLoaded) return this.#local.paused();
- return this.#remote.isPaused || this.ended;
+ if (!this.#remotePlayer.isMediaLoaded) return this.target?.paused ?? true;
+ return this.#remotePlayer.isPaused || this.ended;
}
get ended() {
return (
- this.#remote.playerState === chrome.cast.media.PlayerState.IDLE &&
+ this.#remotePlayer.playerState === chrome.cast.media.PlayerState.IDLE &&
currentMedia()?.idleReason === chrome.cast.media.IdleReason.FINISHED
);
}
+ get seeking() {
+ return this.#seeking;
+ }
+
+ get readyState() {
+ switch (this.#remotePlayer.playerState) {
+ case chrome.cast.media.PlayerState.IDLE:
+ return 0;
+ case chrome.cast.media.PlayerState.BUFFERING:
+ return 2;
+ default:
+ return 3;
+ }
+ }
+
+ get duration() {
+ if (!this.#remotePlayer.isMediaLoaded) return this.target?.duration ?? NaN;
+ return this.#remotePlayer.duration ?? NaN;
+ }
+
+ get currentTime() {
+ if (!this.#remotePlayer.isMediaLoaded) return this.target?.currentTime ?? 0;
+ return this.#remotePlayer.currentTime ?? 0;
+ }
+
+ set currentTime(value: number) {
+ this.#remotePlayer.currentTime = value;
+ this.#notifySeeking();
+ this.#remotePlayer.controller?.seek();
+ }
+
get muted() {
- return this.#remote.isMuted;
+ return this.#remotePlayer.isMuted;
}
set muted(value: boolean) {
- if (value !== this.#remote.isMuted) {
- this.#remote.controller?.muteOrUnmute();
+ if (value !== this.#remotePlayer.isMuted) {
+ this.#remotePlayer.controller?.muteOrUnmute();
}
}
get volume() {
- return this.#remote.volumeLevel ?? 1;
+ return this.#remotePlayer.volumeLevel ?? 1;
}
set volume(value: number) {
- this.#remote.volumeLevel = +value;
- this.#remote.controller?.setVolumeLevel();
+ this.#remotePlayer.volumeLevel = +value;
+ this.#remotePlayer.controller?.setVolumeLevel();
}
get playbackRate() {
@@ -262,48 +273,40 @@ export class GoogleCastProvider {
setPlaybackRate(value);
}
- get duration() {
- if (this.#remote?.isMediaLoaded) return this.#remote.duration ?? NaN;
- return this.#local.duration();
- }
-
- get currentTime() {
- if (this.#remote?.isMediaLoaded) return this.#remote.currentTime ?? 0;
- return this.#local.currentTime();
- }
-
- set currentTime(value: number) {
- this.#remote.currentTime = value;
- this.#notifySeeking();
- this.#remote.controller?.seek();
- }
-
- get readyState() {
- switch (this.#remote.playerState) {
- case chrome.cast.media.PlayerState.IDLE:
- return 0;
- case chrome.cast.media.PlayerState.BUFFERING:
- return 2;
- default:
- return 3;
+ async play() {
+ // When a casted media ends, the media is unloaded and the player state is IDLE.
+ if (!this.#remotePlayer.isMediaLoaded) {
+ this.#localPaused = false;
+ await this.load();
+ return;
+ }
+ if (this.paused) {
+ this.#remotePlayer.controller?.playOrPause();
+ return new Promise((resolve) => {
+ this.target?.addEventListener('play', () => resolve(), { once: true });
+ });
}
}
- init() {
- if (!cf || this.#isInit) return;
+ pause() {
+ if (!this.paused) {
+ this.#remotePlayer.controller?.playOrPause();
+ }
+ }
+
+ onCastFrameworkAvailable() {
+ if (!castFramework || this.#isInit) return;
this.#isInit = true;
this.#applyCastOptions();
- this.media.textTracks.addEventListener('change', this.#onTextTrackChange);
-
this.onCastStateChanged();
- this.#remote = new cf.RemotePlayer();
- new cf.RemotePlayerController(this.#remote);
+ this.#remotePlayer = new castFramework.RemotePlayer();
+ new castFramework.RemotePlayerController(this.#remotePlayer);
this.#remoteListeners = {
- [cf.RemotePlayerEventType.IS_CONNECTED_CHANGED]: (event?: cast.framework.RemotePlayerChangedEvent) => {
+ [castFramework.RemotePlayerEventType.IS_CONNECTED_CHANGED]: (event?: cast.framework.RemotePlayerChangedEvent) => {
const value = event?.value;
if (value === true) {
this.#hooks.setState?.('connected');
@@ -312,44 +315,44 @@ export class GoogleCastProvider {
this.#hooks.setState?.('disconnected');
}
},
- [cf.RemotePlayerEventType.DURATION_CHANGED]: () => {
- this.media.dispatchEvent(new Event('durationchange'));
+ [castFramework.RemotePlayerEventType.DURATION_CHANGED]: () => {
+ this.target?.dispatchEvent(new Event('durationchange'));
},
- [cf.RemotePlayerEventType.VOLUME_LEVEL_CHANGED]: () => {
- this.media.dispatchEvent(new Event('volumechange'));
+ [castFramework.RemotePlayerEventType.VOLUME_LEVEL_CHANGED]: () => {
+ this.target?.dispatchEvent(new Event('volumechange'));
},
- [cf.RemotePlayerEventType.IS_MUTED_CHANGED]: () => {
- this.media.dispatchEvent(new Event('volumechange'));
+ [castFramework.RemotePlayerEventType.IS_MUTED_CHANGED]: () => {
+ this.target?.dispatchEvent(new Event('volumechange'));
},
- [cf.RemotePlayerEventType.CURRENT_TIME_CHANGED]: () => {
- if (!this.isCasting || !this.#remote.isMediaLoaded) return;
+ [castFramework.RemotePlayerEventType.CURRENT_TIME_CHANGED]: () => {
+ if (!this.#isCasting || !this.#remotePlayer.isMediaLoaded) return;
this.#notifySeeked();
- this.media.dispatchEvent(new Event('timeupdate'));
+ this.target?.dispatchEvent(new Event('timeupdate'));
},
- [cf.RemotePlayerEventType.VIDEO_INFO_CHANGED]: () => {
- this.media.dispatchEvent(new Event('resize'));
+ [castFramework.RemotePlayerEventType.VIDEO_INFO_CHANGED]: () => {
+ this.target?.dispatchEvent(new Event('resize'));
},
- [cf.RemotePlayerEventType.IS_PAUSED_CHANGED]: () => {
- this.media.dispatchEvent(new Event(this.isCasting && this.#remote.isPaused ? 'pause' : 'play'));
+ [castFramework.RemotePlayerEventType.IS_PAUSED_CHANGED]: () => {
+ this.target?.dispatchEvent(new Event(this.#isCasting && this.#remotePlayer.isPaused ? 'pause' : 'play'));
},
- [cf.RemotePlayerEventType.PLAYER_STATE_CHANGED]: () => {
+ [castFramework.RemotePlayerEventType.PLAYER_STATE_CHANGED]: () => {
const PS = chrome.cast.media.PlayerState;
- const state = this.isCasting ? this.#remote.playerState : undefined;
+ const state = this.#isCasting ? this.#remotePlayer.playerState : undefined;
if (state !== PS.BUFFERING) this.#notifySeeked();
if (state === PS.PAUSED) return;
if (state === PS.IDLE) {
const finished = currentMedia()?.idleReason === chrome.cast.media.IdleReason.FINISHED;
- this.media.dispatchEvent(new Event(finished ? 'ended' : 'emptied'));
+ this.target?.dispatchEvent(new Event(finished ? 'ended' : 'emptied'));
return;
}
- if (state === PS.PLAYING) this.media.dispatchEvent(new Event('playing'));
- else if (state === PS.BUFFERING) this.media.dispatchEvent(new Event('waiting'));
+ if (state === PS.PLAYING) this.target?.dispatchEvent(new Event('playing'));
+ else if (state === PS.BUFFERING) this.target?.dispatchEvent(new Event('waiting'));
},
- [cf.RemotePlayerEventType.IS_MEDIA_LOADED_CHANGED]: async () => {
- if (!this.isCasting || !this.#remote.isMediaLoaded) return;
+ [castFramework.RemotePlayerEventType.IS_MEDIA_LOADED_CHANGED]: async () => {
+ if (!this.#isCasting || !this.#remotePlayer.isMediaLoaded) return;
await Promise.resolve();
this.#onRemoteMediaLoaded();
@@ -360,9 +363,9 @@ export class GoogleCastProvider {
onCastStateChanged() {
if (!this.#isInit) return;
const CS = cast.framework.CastState;
- const state = castContext()!.getCastState();
+ const state = getCastContext()!.getCastState();
- if (this.isCasting && state === CS.CONNECTING) {
+ if (this.#isCasting && state === CS.CONNECTING) {
this.#hooks.setState?.('connecting');
}
@@ -371,10 +374,10 @@ export class GoogleCastProvider {
async onSessionStateChanged() {
if (!this.#isInit) return;
- const { SESSION_RESUMED } = cf!.SessionState;
- if (castContext()!.getSessionState() === SESSION_RESUMED) {
- if (this.media.castSrc === currentMedia()?.media?.contentId) {
- castElementRef.add(this.media);
+ const { SESSION_RESUMED } = castFramework!.SessionState;
+ if (getCastContext()!.getSessionState() === SESSION_RESUMED) {
+ if (this.#googleCast.src === currentMedia()?.media?.contentId) {
+ this.#isCasting = true;
this.#attachRemoteListeners();
@@ -384,30 +387,22 @@ export class GoogleCastProvider {
console.error(error);
}
- this.#remoteListeners[cf!.RemotePlayerEventType.IS_PAUSED_CHANGED]!();
- this.#remoteListeners[cf!.RemotePlayerEventType.PLAYER_STATE_CHANGED]!();
- this.media.dispatchEvent(new Event('ratechange'));
+ this.#remoteListeners[castFramework!.RemotePlayerEventType.IS_PAUSED_CHANGED]!();
+ this.#remoteListeners[castFramework!.RemotePlayerEventType.PLAYER_STATE_CHANGED]!();
+ this.target?.dispatchEvent(new Event('ratechange'));
// TODO: sync remote enabled text track state to local text tracks
}
}
}
- destroy() {
- providerInstances.delete(this);
- currentMedia()?.removeUpdateListener(this.#onMediaUpdate);
- this.media.textTracks?.removeEventListener('change', this.#onTextTrackChange);
- this.#detachRemoteListeners();
- this.isCasting = false;
- this.#isInit = false;
- }
-
#isCastApiAvailable() {
return Boolean(globalThis.chrome?.cast?.isAvailable);
}
#applyCastOptions() {
- setCastOptions(this.media.castOptions);
+ const { receiver } = this.#googleCast;
+ setCastOptions(receiver ? { receiverApplicationId: receiver } : {});
}
// CAF's RemotePlayerController does not deduplicate handlers, so we guard
@@ -415,7 +410,7 @@ export class GoogleCastProvider {
// cancel/retry and stop-casting flows.
#attachRemoteListeners() {
if (this.#listenersAttached) return;
- const controller = this.#remote?.controller;
+ const controller = this.#remotePlayer?.controller;
if (!controller) return;
for (const [type, handler] of Object.entries(this.#remoteListeners)) {
@@ -426,7 +421,7 @@ export class GoogleCastProvider {
#detachRemoteListeners() {
if (!this.#listenersAttached) return;
- const controller = this.#remote?.controller;
+ const controller = this.#remotePlayer?.controller;
if (controller) {
for (const [type, handler] of Object.entries(this.#remoteListeners)) {
@@ -437,31 +432,38 @@ export class GoogleCastProvider {
}
#disconnect() {
- if (!this.isCasting) return;
+ if (!this.#isCasting) return;
currentMedia()?.removeUpdateListener(this.#onMediaUpdate);
this.#detachRemoteListeners();
- this.seeking = false;
+ this.#seeking = false;
this.#playbackRate = 1;
- this.isCasting = false;
+ this.#isCasting = false;
- this.media.muted = this.#remote.isMuted;
- const saved = this.#remote.savedPlayerState;
+ if (this.target) {
+ this.target.muted = this.#remotePlayer.isMuted;
+ }
+
+ const saved = this.#remotePlayer.savedPlayerState;
if (saved) {
- this.media.currentTime = saved.currentTime;
- if (saved.isPaused === false) this.media.play();
+ if (this.target) {
+ this.target.currentTime = saved.currentTime;
+ }
+ if (saved.isPaused === false && this.target) {
+ this.target.play();
+ }
}
}
#notifySeeking() {
- this.seeking = true;
- this.media.dispatchEvent(new Event('seeking'));
+ this.#seeking = true;
+ this.target?.dispatchEvent(new Event('seeking'));
}
#notifySeeked() {
- if (!this.seeking) return;
- this.seeking = false;
- this.media.dispatchEvent(new Event('seeked'));
+ if (!this.#seeking) return;
+ this.#seeking = false;
+ this.target?.dispatchEvent(new Event('seeked'));
}
#onRemoteMediaLoaded() {
@@ -474,16 +476,16 @@ export class GoogleCastProvider {
const rate = currentMedia()?.playbackRate ?? 1;
if (rate !== this.#playbackRate) {
this.#playbackRate = rate;
- this.media.dispatchEvent(new Event('ratechange'));
+ this.target?.dispatchEvent(new Event('ratechange'));
}
}
async #updateRemoteTextTrack() {
- if (!this.isCasting) return;
+ if (!this.#isCasting || !this.target) return;
- const localSubs = [...this.media.textTracks].filter(isCaptionOrSubtitleTrack);
+ const localSubs = [...this.target.textTracks].filter(isCaptionOrSubtitleTrack);
- const matched = (this.#remote.mediaInfo?.tracks ?? [])
+ const matched = (this.#remotePlayer.mediaInfo?.tracks ?? [])
.filter(({ type }) => type === chrome.cast.media.TrackType.TEXT)
.flatMap(({ language, name, trackId }) => {
const local = localSubs.find((l) => l.language === language && l.label === name);
diff --git a/packages/core/src/dom/media/google-cast/index.ts b/packages/core/src/dom/media/google-cast/index.ts
index f069c796..530935ee 100644
--- a/packages/core/src/dom/media/google-cast/index.ts
+++ b/packages/core/src/dom/media/google-cast/index.ts
@@ -1,246 +1,151 @@
-import type { MixinReturn } from '@videojs/utils/types';
-import type { RemotePlaybackLike } from '../../../core/media/types';
+import type { MediaStreamType } from '../../../core/media/types';
+import type { Component, HTMLMediaElementHost, HTMLMediaTargetLike } from '../media-host';
import { GoogleCastProvider } from './google-cast-provider';
-import { RemotePlayback } from './remote-playback';
-import type { GoogleCastMedia, GoogleCastMediaHostConstructor } from './types';
-import { getDefaultCastOptions, loadCastFramework, requiresCastFramework } from './utils';
+import { requiresCastFramework } from './utils';
-export type { GoogleCastMediaElement } from './types';
+type MediaHost = HTMLMediaElementHost;
-export const GoogleCastMixin = (
- superclass: Base
-): MixinReturn => {
- class GoogleCastMedia extends superclass {
- #castOptions = getDefaultCastOptions();
- #castCustomData: Record | null | undefined;
- #castSrc: string | undefined;
- #castContentType: string | undefined;
- #castStreamType: string | undefined;
- #castReceiver: string | undefined;
- #remote: RemotePlayback | null | undefined;
- #provider: GoogleCastProvider | null | undefined;
- #destroyed = false;
+export interface GoogleCastProps {
+ /** Source URL loaded on the Cast receiver. Falls back to the host's `src` / `currentSrc`. */
+ src?: string | undefined;
+ /** MIME type of the Cast source. When unset, the receiver infers it from the URL. */
+ contentType?: string | undefined;
+ /** Stream type used on the Cast receiver. */
+ streamType?: MediaStreamType | undefined;
+ /** Cast receiver application ID. Defaults to Google's default media receiver. */
+ receiver?: string | undefined;
+ /** Custom data sent to the Cast receiver with the load request. */
+ customData?: Record | null | undefined;
+}
- get remote(): RemotePlayback | RemotePlaybackLike | undefined {
- if (this.#remote) return this.#remote;
- if (this.#destroyed) return super.remote;
+declare module '../media-host' {
+ interface MediaComponentConfig {
+ googleCast: GoogleCastProps;
+ }
+}
- if (requiresCastFramework()) {
- if (!this.disableRemotePlayback) {
- loadCastFramework();
- }
+export class GoogleCast implements GoogleCastProps, Component {
+ static readonly configKey = 'googleCast';
- this.#provider = new GoogleCastProvider(this, {
- duration: () => super.duration,
- currentTime: () => super.currentTime,
- paused: () => super.paused,
- muted: () => super.muted,
- pause: () => super.pause(),
- });
- return (this.#remote = new RemotePlayback(this.#provider));
- }
+ #src: string | undefined;
+ #contentType: string | undefined;
+ #streamType: MediaStreamType | undefined;
+ #receiver: string | undefined;
+ #customData: Record | null | undefined;
+ #media: MediaHost | null = null;
+ #provider: GoogleCastProvider | null = null;
+ #override: Partial | null = null;
- return super.remote;
- }
-
- attach(target: HTMLMediaElement) {
- super.attach(target);
-
- if (requiresCastFramework() && !this.disableRemotePlayback) {
- loadCastFramework();
- }
- }
-
- detach() {
- super.detach();
- }
-
- destroy() {
- this.#provider?.destroy();
- this.#provider = null;
- this.#remote = null;
- this.#destroyed = true;
- super.destroy();
- }
-
- async load() {
- if (!this.#provider?.isCasting) return super.load();
- return this.#provider.load();
- }
-
- play() {
- if (this.#provider?.isCasting) {
- return this.#provider.play();
- }
- return super.play();
- }
-
- pause() {
- if (this.#provider?.isCasting) {
- this.#provider.pause();
- return;
- }
- super.pause();
- }
-
- /** Resolved options passed to the Google Cast framework when it initializes. */
- get castOptions() {
- return this.#castOptions;
- }
-
- /** Cast receiver application ID. Defaults to Google's default media receiver. */
- get castReceiver() {
- return this.#castReceiver;
- }
-
- set castReceiver(val: string | undefined) {
- // Normalize empty/nullish values to `undefined` so the type returned from
- // the getter stays `string | undefined`. The `CustomMediaElement` wrapper
- // coerces removed attributes to `''` when routing through
- // `attributeChangedCallback`.
- const next = val || undefined;
- if (this.#castReceiver === next) return;
- this.#castReceiver = next;
-
- if (next) {
- this.#castOptions.receiverApplicationId = next;
- }
- }
-
- /** Source URL loaded on the Cast receiver. Falls back to a `` child, `src`, then `currentSrc`. */
- get castSrc() {
- return this.#castSrc ?? this.querySelector('source')?.src ?? this.src ?? this.currentSrc;
- }
-
- set castSrc(val: string | undefined) {
- // Normalize empty/nullish values to `undefined` so the fallback chain in the
- // getter (via `??`) still applies. The `CustomMediaElement` wrapper coerces
- // removed attributes to `''` when routing through `attributeChangedCallback`,
- // which would otherwise short-circuit the fallback and break Cast loading.
- const next = val || undefined;
- if (this.#castSrc === next) return;
- this.#castSrc = next;
-
- if (this.#provider?.isCasting) this.load();
- }
-
- /** MIME type of the Cast source. When unset, the receiver infers it from the URL. */
- get castContentType() {
- return this.#castContentType;
- }
-
- set castContentType(val: string | undefined) {
- // Normalize empty/nullish values to `undefined` so the type returned from
- // the getter stays `string | undefined`. The `CustomMediaElement` wrapper
- // coerces removed attributes to `''` when routing through
- // `attributeChangedCallback`.
- this.#castContentType = val || undefined;
- }
-
- /** Stream type (`'on-demand'` or `'live'`) used on the Cast receiver. Falls back to `streamType`. */
- get castStreamType() {
- return this.#castStreamType ?? this.streamType;
- }
-
- set castStreamType(val: string | undefined) {
- if (this.#castStreamType === val) return;
- this.#castStreamType = val;
-
- if (this.#provider?.isCasting) this.load();
- }
-
- /** Custom data sent to the Cast receiver with the load request. */
- get castCustomData() {
- return this.#castCustomData;
- }
-
- set castCustomData(val: Record | null | undefined) {
- const valType = typeof val;
- if (!['object', 'undefined'].includes(valType)) {
- console.error(`castCustomData must be nullish or an object but value was of type ${valType}`);
- return;
- }
-
- this.#castCustomData = val;
- }
-
- get seeking() {
- if (this.#provider?.isCasting) return this.#provider.seeking;
- return super.seeking;
- }
-
- get readyState() {
- if (this.#provider?.isCasting) return this.#provider.readyState;
- return super.readyState;
- }
-
- get paused() {
- if (this.#provider?.isCasting) return this.#provider.paused;
- return super.paused;
- }
-
- get ended() {
- if (this.#provider?.isCasting) return this.#provider.ended;
- return super.ended;
- }
-
- get muted() {
- if (this.#provider?.isCasting) return this.#provider.muted;
- return super.muted;
- }
-
- set muted(val: boolean) {
- if (this.#provider?.isCasting) {
- this.#provider.muted = val;
- return;
- }
- super.muted = val;
- }
-
- get volume() {
- if (this.#provider?.isCasting) return this.#provider.volume;
- return super.volume;
- }
-
- set volume(val: number) {
- if (this.#provider?.isCasting) {
- this.#provider.volume = val;
- return;
- }
- super.volume = val;
- }
-
- get playbackRate() {
- if (this.#provider?.isCasting) return this.#provider.playbackRate;
- return super.playbackRate;
- }
-
- set playbackRate(val: number) {
- if (this.#provider?.isCasting) {
- this.#provider.playbackRate = val;
- return;
- }
- super.playbackRate = val;
- }
-
- get duration() {
- if (this.#provider?.isCasting) return this.#provider.duration;
- return super.duration;
- }
-
- get currentTime() {
- if (this.#provider?.isCasting) return this.#provider.currentTime;
- return super.currentTime;
- }
-
- set currentTime(val: number) {
- if (this.#provider?.isCasting) {
- this.#provider.currentTime = val;
- return;
- }
- super.currentTime = val;
- }
+ constructor(props: GoogleCastProps = {}) {
+ Object.assign(this, props);
}
- return GoogleCastMedia as unknown as MixinReturn;
-};
+ setMedia(host: MediaHost) {
+ if (!requiresCastFramework()) return;
+
+ 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);
+ }
+
+ attach(target: HTMLMediaTargetLike) {
+ this.#provider?.attach(target);
+ }
+
+ detach() {
+ this.#provider?.detach();
+ }
+
+ destroy() {
+ this.#provider?.destroy();
+ this.#provider = null;
+ this.#media = null;
+ }
+
+ #onStateChange = () => {
+ if (!this.#provider) return;
+
+ if (this.#provider.remote.state === 'connected') {
+ this.#override = this.#provider;
+ } else {
+ this.#override = this.#createRemoteOverride();
+ }
+ };
+
+ #createRemoteOverride(): Partial {
+ const provider = this.#provider!;
+ return {
+ get remote() {
+ return provider.remote;
+ },
+ };
+ }
+
+ get targetOverride() {
+ return this.#override;
+ }
+
+ /** Source URL loaded on the Cast receiver. Falls back to a `` child, `src`, then `currentSrc`. */
+ get src() {
+ return this.#src ?? this.#media?.querySelector('source')?.src ?? this.#media?.src ?? this.#media?.currentSrc ?? '';
+ }
+
+ set src(value: string) {
+ if (this.#src === value) return;
+ this.#src = value;
+ this.#load();
+ }
+
+ /** MIME type of the Cast source. When unset, the receiver infers it from the URL. */
+ get contentType() {
+ return this.#contentType;
+ }
+
+ set contentType(value: string | undefined) {
+ if (this.#contentType === value) return;
+ this.#contentType = value;
+ this.#load();
+ }
+
+ /** Stream type used on the Cast receiver. Falls back to the host's `streamType` if it exposes one. */
+ get streamType() {
+ return this.#streamType ?? (this.#media as { streamType?: MediaStreamType } | null)?.streamType;
+ }
+
+ set streamType(value: MediaStreamType | undefined) {
+ if (this.#streamType === value) return;
+ this.#streamType = value;
+ this.#load();
+ }
+
+ /** Cast receiver application ID. Read on session start; falls back to the layer's default. */
+ get receiver() {
+ return this.#receiver;
+ }
+
+ set receiver(value: string | undefined) {
+ if (this.#receiver === value) return;
+ this.#receiver = value;
+ this.#load();
+ }
+
+ /** Custom data sent to the Cast receiver with the load request. */
+ get customData() {
+ return this.#customData;
+ }
+
+ set customData(value: Record | null | undefined) {
+ if (this.#customData === value) return;
+ this.#customData = value;
+ this.#load();
+ }
+
+ #load() {
+ if (this.#media?.remote.state === 'connected') {
+ this.#media.load();
+ }
+ }
+}
diff --git a/packages/core/src/dom/media/google-cast/registry.ts b/packages/core/src/dom/media/google-cast/registry.ts
new file mode 100644
index 00000000..4680dda8
--- /dev/null
+++ b/packages/core/src/dom/media/google-cast/registry.ts
@@ -0,0 +1,61 @@
+import type { GoogleCastProvider } from './google-cast-provider';
+import { getCastContext, IterableWeakSet, loadCastFramework, onCastApiAvailable } from './utils';
+
+export const googleCastInstances = new IterableWeakSet();
+
+export let castFramework: typeof cast.framework | undefined;
+
+let pendingCastFramework: Promise | null = null;
+
+export async function ensureCastFramework(): Promise {
+ if (castFramework) return castFramework;
+
+ if (!pendingCastFramework) {
+ pendingCastFramework = loadCastFramework().then(
+ () =>
+ new Promise((resolve, reject) => {
+ onCastApiAvailable(() => {
+ registerCastFramework();
+
+ if (castFramework) {
+ resolve(castFramework);
+ return;
+ }
+
+ reject(new DOMException('Google Cast framework is unavailable.', 'NotSupportedError'));
+ });
+ })
+ );
+
+ // Reset on failure (e.g. script blocked by an ad-blocker) so a later
+ // attempt can retry instead of replaying a cached rejection.
+ pendingCastFramework.catch(() => {
+ pendingCastFramework = null;
+ });
+ }
+
+ return pendingCastFramework;
+}
+
+function registerCastFramework(): void {
+ if (!globalThis.chrome?.cast?.isAvailable) {
+ if (__DEV__) {
+ console.debug('chrome.cast.isAvailable', globalThis.chrome?.cast?.isAvailable);
+ }
+ return;
+ }
+
+ if (!castFramework) {
+ castFramework = cast.framework;
+
+ getCastContext()!.addEventListener(castFramework.CastContextEventType.CAST_STATE_CHANGED, () => {
+ googleCastInstances.forEach((provider) => provider.onCastStateChanged());
+ });
+
+ getCastContext()!.addEventListener(castFramework.CastContextEventType.SESSION_STATE_CHANGED, () => {
+ googleCastInstances.forEach((provider) => provider.onSessionStateChanged());
+ });
+
+ googleCastInstances.forEach((provider) => provider.onCastFrameworkAvailable());
+ }
+}
diff --git a/packages/core/src/dom/media/google-cast/remote-playback.ts b/packages/core/src/dom/media/google-cast/remote-playback.ts
index 06cc0ad8..3dbfe3bf 100644
--- a/packages/core/src/dom/media/google-cast/remote-playback.ts
+++ b/packages/core/src/dom/media/google-cast/remote-playback.ts
@@ -5,8 +5,28 @@ export type RemotePlaybackState = 'disconnected' | 'connecting' | 'connected';
type AvailabilityCallback = (available: boolean) => void;
+export type RemotePlaybackHooks = {
+ setState(next: RemotePlaybackState): void;
+ setAvailable(available: boolean): void;
+};
+
let callbackIdCount = 0;
+/**
+ * Implementation of the W3C [`RemotePlayback`](https://developer.mozilla.org/en-US/docs/Web/API/RemotePlayback)
+ * interface backed by Google Cast.
+ *
+ * Surfaced via `host.remote` while the {@link GoogleCastProvider} is in the
+ * provider chain. The public API must strictly conform to the W3C spec:
+ *
+ * - Properties: `state`
+ * - Methods: `watchAvailability`, `cancelWatchAvailability`, `prompt`
+ * - Events: `connecting`, `connect`, `disconnect`
+ *
+ * Internal state mutations are pushed by {@link GoogleCastProvider} through
+ * private callbacks registered via `provider.bindHooks(...)` in the constructor —
+ * do not add public methods or properties that aren't part of the spec.
+ */
export class RemotePlayback extends EventTarget {
#provider: GoogleCastProvider;
#state: RemotePlaybackState = 'disconnected';
@@ -53,7 +73,7 @@ export class RemotePlayback extends EventTarget {
}
#assertEnabled() {
- if (this.#provider.media.disableRemotePlayback) {
+ if (this.#provider.target?.disableRemotePlayback) {
throw new InvalidStateError('disableRemotePlayback attribute is present.');
}
}
diff --git a/packages/core/src/dom/media/google-cast/tests/google-cast-provider.test.ts b/packages/core/src/dom/media/google-cast/tests/google-cast-provider.test.ts
new file mode 100644
index 00000000..8078a569
--- /dev/null
+++ b/packages/core/src/dom/media/google-cast/tests/google-cast-provider.test.ts
@@ -0,0 +1,128 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { addComponent, type HTMLMediaTargetLike } from '../../media-host';
+import { HTMLVideoElementHost } from '../../video-host';
+import { GoogleCastProvider } from '../google-cast-provider';
+import { GoogleCast } from '../index';
+import { ensureCastFramework } from '../registry';
+
+vi.mock('../registry', async (importOriginal) => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ ensureCastFramework: vi.fn(() => Promise.resolve({} as typeof cast.framework)),
+ };
+});
+
+// The test environment's `video.textTracks` is not a spy-friendly EventTarget,
+// so use a minimal structural target instead of a real element.
+function createTarget(disableRemotePlayback = false) {
+ const textTracks = new EventTarget();
+ const target = Object.assign(new EventTarget(), {
+ textTracks,
+ disableRemotePlayback,
+ }) as unknown as HTMLMediaTargetLike;
+ return { target, textTracks };
+}
+
+beforeEach(() => {
+ vi.mocked(ensureCastFramework).mockClear();
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('GoogleCastProvider', () => {
+ it('does not load the cast framework before a target is attached', () => {
+ const provider = new GoogleCastProvider({});
+
+ void provider.remote;
+
+ expect(ensureCastFramework).not.toHaveBeenCalled();
+ });
+
+ it('does not load the cast framework on attach', () => {
+ const provider = new GoogleCastProvider({});
+ const { target } = createTarget();
+
+ provider.attach(target);
+
+ expect(ensureCastFramework).not.toHaveBeenCalled();
+ });
+
+ it('loads the cast framework when remote is read while attached', () => {
+ const provider = new GoogleCastProvider({});
+ const { target } = createTarget();
+
+ provider.attach(target);
+ void provider.remote;
+
+ expect(ensureCastFramework).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not load the cast framework when remote playback is disabled', () => {
+ const provider = new GoogleCastProvider({});
+ const { target } = createTarget(true);
+
+ provider.attach(target);
+ void provider.remote;
+
+ expect(ensureCastFramework).not.toHaveBeenCalled();
+ });
+
+ it('adds and removes the text track change listener on attach/detach', () => {
+ const provider = new GoogleCastProvider({});
+ const { target, textTracks } = createTarget();
+ const add = vi.spyOn(textTracks, 'addEventListener');
+ const remove = vi.spyOn(textTracks, 'removeEventListener');
+
+ provider.attach(target);
+ expect(add).toHaveBeenCalledWith('change', expect.any(Function));
+
+ provider.detach();
+ expect(remove).toHaveBeenCalledWith('change', add.mock.calls[0]![1]);
+ });
+
+ it('removes the text track change listener on destroy', () => {
+ const provider = new GoogleCastProvider({});
+ const { target, textTracks } = createTarget();
+ const add = vi.spyOn(textTracks, 'addEventListener');
+ const remove = vi.spyOn(textTracks, 'removeEventListener');
+
+ provider.attach(target);
+ provider.destroy();
+
+ expect(remove).toHaveBeenCalledWith('change', add.mock.calls[0]![1]);
+ expect(provider.target).toBeNull();
+ });
+});
+
+describe('GoogleCast', () => {
+ it('loads the cast framework when the host remote is read while attached', () => {
+ vi.stubGlobal('chrome', {});
+
+ const host = new HTMLVideoElementHost();
+ const { target } = createTarget();
+ host.attach(target as Parameters[0]);
+
+ addComponent(host, new GoogleCast());
+ expect(ensureCastFramework).not.toHaveBeenCalled();
+
+ // The component's override must expose `remote` as an accessor so host
+ // reads reach the provider's lazy-loading getter.
+ void host.remote;
+
+ expect(ensureCastFramework).toHaveBeenCalled();
+ });
+
+ it('does not load the cast framework when the host remote is read before attach', () => {
+ vi.stubGlobal('chrome', {});
+
+ const host = new HTMLVideoElementHost();
+ addComponent(host, new GoogleCast());
+
+ void host.remote;
+
+ expect(ensureCastFramework).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/core/src/dom/media/google-cast/tests/index.test.ts b/packages/core/src/dom/media/google-cast/tests/index.test.ts
index 8fed2edd..b0058b10 100644
--- a/packages/core/src/dom/media/google-cast/tests/index.test.ts
+++ b/packages/core/src/dom/media/google-cast/tests/index.test.ts
@@ -1,71 +1,136 @@
-import { describe, expect, it } from 'vitest';
-import { MuxVideoMedia } from '../../mux';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { addComponent } from '../../media-host';
+import { HTMLVideoElementHost } from '../../video-host';
+import { GoogleCast } from '../index';
-describe('GoogleCastMixin', () => {
- describe('castReceiver', () => {
- it('stores a non-empty string value', () => {
- const media = new MuxVideoMedia();
- media.castReceiver = 'ABC123';
- expect(media.castReceiver).toBe('ABC123');
+const mocks = vi.hoisted(() => {
+ class FakeRemote extends EventTarget {
+ state: 'disconnected' | 'connecting' | 'connected' = 'disconnected';
+ }
+
+ class FakeProvider {
+ static instances: FakeProvider[] = [];
+
+ remote = new FakeRemote();
+ currentTime = 42;
+ muted = false;
+ load = vi.fn();
+ attach = vi.fn();
+ detach = vi.fn();
+ destroy = vi.fn();
+
+ constructor(public config: unknown) {
+ FakeProvider.instances.push(this);
+ }
+ }
+
+ return { FakeProvider };
+});
+
+vi.mock('../google-cast-provider', () => ({
+ GoogleCastProvider: mocks.FakeProvider,
+}));
+
+function setup() {
+ const host = new HTMLVideoElementHost();
+ const video = document.createElement('video');
+ host.attach(video);
+
+ const googleCast = new GoogleCast();
+ addComponent(host, googleCast);
+
+ const provider = mocks.FakeProvider.instances.at(-1)!;
+ return { host, video, googleCast, provider };
+}
+
+function connect(provider: InstanceType) {
+ provider.remote.state = 'connected';
+ provider.remote.dispatchEvent(new Event('connect'));
+}
+
+function disconnect(provider: InstanceType) {
+ provider.remote.state = 'disconnected';
+ provider.remote.dispatchEvent(new Event('disconnect'));
+}
+
+beforeEach(() => {
+ // `requiresCastFramework()` requires a Chromium-like environment.
+ vi.stubGlobal('chrome', {});
+ mocks.FakeProvider.instances.length = 0;
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('GoogleCast', () => {
+ describe('override swap on connect/disconnect', () => {
+ it('routes host reads to the target while disconnected', () => {
+ const { host, googleCast, provider } = setup();
+
+ expect(googleCast.targetOverride).not.toBe(provider);
+ expect(host.currentTime).toBe(0);
});
- it('coerces empty string to undefined', () => {
- const media = new MuxVideoMedia();
- media.castReceiver = 'ABC123';
- media.castReceiver = '';
- expect(media.castReceiver).toBeUndefined();
+ it('swaps the target override to the provider on connect', () => {
+ const { host, googleCast, provider } = setup();
+
+ connect(provider);
+
+ expect(googleCast.targetOverride).toBe(provider);
+ expect(host.currentTime).toBe(42);
});
- it('stores undefined when set to undefined', () => {
- const media = new MuxVideoMedia();
- media.castReceiver = 'ABC123';
- media.castReceiver = undefined;
- expect(media.castReceiver).toBeUndefined();
+ it('restores the remote-only override on disconnect', () => {
+ const { host, googleCast, provider } = setup();
+
+ connect(provider);
+ disconnect(provider);
+
+ expect(googleCast.targetOverride).not.toBe(provider);
+ expect(host.currentTime).toBe(0);
+ // The override still exposes `remote` through the provider accessor.
+ expect(host.remote).toBe(provider.remote);
});
- it('updates the castOptions receiverApplicationId when set', () => {
- const media = new MuxVideoMedia();
- media.castReceiver = 'ABC123';
- expect(media.castOptions.receiverApplicationId).toBe('ABC123');
+ it('routes property writes to the provider while connected', () => {
+ const { host, video, provider } = setup();
+
+ connect(provider);
+ host.muted = true;
+
+ expect(provider.muted).toBe(true);
+ expect(video.muted).toBe(false);
});
- it('keeps the previous receiverApplicationId when cleared', () => {
- const media = new MuxVideoMedia();
- media.castReceiver = 'ABC123';
- media.castReceiver = '';
- expect(media.castOptions.receiverApplicationId).toBe('ABC123');
+ it('routes property writes to the target after disconnect', () => {
+ const { host, video, provider } = setup();
+
+ connect(provider);
+ disconnect(provider);
+ host.muted = true;
+
+ expect(provider.muted).toBe(false);
+ expect(video.muted).toBe(true);
});
});
- describe('castContentType', () => {
- it('stores a non-empty string value', () => {
- const media = new MuxVideoMedia();
- media.castContentType = 'application/x-mpegURL';
- expect(media.castContentType).toBe('application/x-mpegURL');
+ describe('cast prop changes', () => {
+ it('reloads the media when a cast prop changes while connected', () => {
+ const { googleCast, provider } = setup();
+
+ connect(provider);
+ googleCast.src = 'https://example.com/stream.m3u8';
+
+ expect(provider.load).toHaveBeenCalledTimes(1);
});
- it('coerces empty string to undefined', () => {
- const media = new MuxVideoMedia();
- media.castContentType = 'application/x-mpegURL';
- media.castContentType = '';
- expect(media.castContentType).toBeUndefined();
- });
+ it('does not reload the media while disconnected', () => {
+ const { googleCast, provider } = setup();
- it('stores undefined when set to undefined', () => {
- const media = new MuxVideoMedia();
- media.castContentType = 'application/x-mpegURL';
- media.castContentType = undefined;
- expect(media.castContentType).toBeUndefined();
- });
- });
+ googleCast.src = 'https://example.com/stream.m3u8';
- describe('castSrc', () => {
- it('coerces empty string to undefined so getter fallback applies', () => {
- const media = new MuxVideoMedia();
- media.castSrc = 'https://example.com/cast.m3u8';
- media.castSrc = '';
- media.src = 'https://example.com/video.m3u8';
- expect(media.castSrc).toBe('https://example.com/video.m3u8');
+ expect(provider.load).not.toHaveBeenCalled();
});
});
});
diff --git a/packages/core/src/dom/media/google-cast/types.ts b/packages/core/src/dom/media/google-cast/types.ts
deleted file mode 100644
index cf64631a..00000000
--- a/packages/core/src/dom/media/google-cast/types.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import type { RemotePlaybackLike } from '../../../core/media/types';
-import type { StreamType } from '../hls/index';
-import type { RemotePlayback } from './remote-playback';
-import type { CastOptions } from './utils';
-
-export interface GoogleCastMediaProps {
- castSrc: string | undefined;
- castReceiver: string | undefined;
- castContentType: string | undefined;
- castStreamType: string | undefined;
- castCustomData: Record | null | undefined;
-}
-
-export const googleCastMediaDefaultProps: GoogleCastMediaProps = {
- castSrc: undefined,
- castReceiver: undefined,
- castContentType: undefined,
- castStreamType: undefined,
- castCustomData: undefined,
-};
-
-export interface GoogleCastMediaHost extends EventTarget {
- readonly target: HTMLMediaElement | null;
- readonly remote: RemotePlaybackLike | undefined;
- title: string;
- poster: string;
- src: string;
- currentSrc: string;
- currentTime: number;
- duration: number;
- muted: boolean;
- paused: boolean;
- ended: boolean;
- loop: boolean;
- seeking: boolean;
- readyState: number;
- volume: number;
- playbackRate: number;
- streamType?: StreamType;
- textTracks: TextTrackList;
- disableRemotePlayback: boolean;
- load(): void | Promise;
- play(): void | Promise;
- pause(): void;
- attach(target: HTMLMediaElement): void;
- detach(): void;
- destroy(): void;
- querySelectorAll(selectors: K): Iterable;
- querySelector(selectors: K): HTMLElementTagNameMap[K] | null;
-}
-
-export interface GoogleCastMedia extends GoogleCastMediaProps {
- readonly remote: RemotePlayback | RemotePlaybackLike | undefined;
- readonly castOptions: CastOptions;
- poster: string;
- title: string;
-}
-
-export type GoogleCastMediaElement = GoogleCastMediaHost & GoogleCastMedia;
-
-export interface GoogleCastMediaHostConstructor {
- new (...args: any[]): GoogleCastMediaHost;
-}
diff --git a/packages/core/src/dom/media/google-cast/utils.ts b/packages/core/src/dom/media/google-cast/utils.ts
index 44ac5e6e..ba6626e6 100644
--- a/packages/core/src/dom/media/google-cast/utils.ts
+++ b/packages/core/src/dom/media/google-cast/utils.ts
@@ -1,7 +1,11 @@
+import { loadScript } from '@videojs/utils/dom';
+
export class InvalidStateError extends Error {}
export class NotSupportedError extends Error {}
export class NotFoundError extends Error {}
+export const GOOGLE_CAST_FRAMEWORK_URL = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
+
const HLS_RESPONSE_HEADERS = ['application/x-mpegURL', 'application/vnd.apple.mpegurl', 'audio/mpegurl'];
export class IterableWeakSet {
@@ -49,21 +53,17 @@ export function requiresCastFramework() {
return Boolean(globalThis.chrome);
}
-export function loadCastFramework() {
- const sdkUrl = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
- if (globalThis.chrome?.cast || document.querySelector(`script[src="${sdkUrl}"]`)) return;
-
- const script = document.createElement('script');
- script.src = sdkUrl;
- document.head.append(script);
+export async function loadCastFramework() {
+ if (globalThis.chrome?.cast) return;
+ await loadScript(GOOGLE_CAST_FRAMEWORK_URL);
}
-export function castContext() {
+export function getCastContext() {
return typeof cast === 'undefined' ? undefined : cast.framework?.CastContext.getInstance();
}
export function currentSession() {
- return castContext()?.getCurrentSession();
+ return getCastContext()?.getCurrentSession();
}
export function currentMedia() {
@@ -98,7 +98,7 @@ export function setPlaybackRate(rate: number) {
export type CastOptions = cast.framework.CastOptions;
export function setCastOptions(options: Partial) {
- castContext()!.setOptions({
+ getCastContext()!.setOptions({
...getDefaultCastOptions(),
...options,
});
diff --git a/packages/core/src/dom/media/hls/index.ts b/packages/core/src/dom/media/hls/index.ts
index 15f41ebc..af8972c9 100644
--- a/packages/core/src/dom/media/hls/index.ts
+++ b/packages/core/src/dom/media/hls/index.ts
@@ -1,7 +1,8 @@
import { shallowEqual } from '@videojs/utils/object';
-import Hls from 'hls.js';
+import Hls, { type HlsConfig as HlsJsConfig } from 'hls.js';
import { type MediaStreamType, MediaStreamTypes } from '../../../core/media/types';
import { bridgeEvents } from '../../../core/utils/bridge-events';
+import type { MediaConfig } from '../media-host';
import { NativeHlsMedia } from '../native-hls';
import { HTMLVideoElementHost } from '../video-host';
import { HlsJsMedia } from './hlsjs';
@@ -11,7 +12,7 @@ export type PreloadType = '' | 'none' | 'metadata' | 'auto';
export { Hls };
export type PlaybackType = (typeof PlaybackTypes)[keyof typeof PlaybackTypes];
-export type SourceType = (typeof SourceTypes)[keyof typeof SourceTypes];
+export type SourceType = (typeof ContentTypes)[keyof typeof ContentTypes];
export type StreamType = MediaStreamType;
export const PlaybackTypes = {
@@ -19,7 +20,7 @@ export const PlaybackTypes = {
NATIVE: 'native',
};
-export const SourceTypes = {
+export const ContentTypes = {
M3U8: 'application/vnd.apple.mpegurl',
MP4: 'video/mp4',
};
@@ -28,41 +29,74 @@ export const StreamTypes = MediaStreamTypes;
export interface HlsMediaProps {
src: string;
- type: SourceType | undefined;
- preferPlayback: PlaybackType | undefined;
- config: Record;
- debug: boolean;
preload: PreloadType;
streamType: StreamType;
+ config?: HlsMediaConfig;
+}
+
+export interface HlsMediaConfig extends MediaConfig {
+ preferPlayback?: PlaybackType | undefined;
+ contentType?: SourceType | undefined;
+ hlsJs?: Partial;
}
export const hlsMediaDefaultProps: HlsMediaProps = {
src: '',
- type: undefined,
- preferPlayback: 'mse',
- config: {},
- debug: false,
preload: 'metadata',
streamType: MediaStreamTypes.UNKNOWN,
+ config: {},
};
+class HlsMediaEvent extends Event {}
+
export class HlsMedia extends HTMLVideoElementHost implements HlsMediaProps {
#delegate: HlsJsMedia | NativeHlsMedia | null = null;
+ #mediaElement: HTMLVideoElement | null = null;
#src = hlsMediaDefaultProps.src;
- #type = hlsMediaDefaultProps.type;
- #preferPlayback = hlsMediaDefaultProps.preferPlayback;
- #config = { ...hlsMediaDefaultProps.config };
- #debug = hlsMediaDefaultProps.debug;
#preload = hlsMediaDefaultProps.preload;
#streamType: StreamType = hlsMediaDefaultProps.streamType;
#isUserStreamType = false;
#loadRequested?: Promise | null;
- #prevEngineProps?: Record | null;
+ #prevEngineConfigKey?: Record | null;
+
+ constructor() {
+ super();
+ // Cancel the native loadstart event, it's handled in the load method.
+ this.addEventListener('loadstart', this.#stopTargetLoadStartEvent);
+ }
+
+ attach(target: HTMLVideoElement) {
+ this.#mediaElement = target;
+ super.attach(target);
+ this.#delegate?.attach(target);
+ }
+
+ detach() {
+ this.#delegate?.detach();
+ super.detach();
+ this.#mediaElement = null;
+ }
+
+ destroy() {
+ this.detach();
+ this.#engineDestroy();
+ super.destroy();
+ this.removeEventListener('loadstart', this.#stopTargetLoadStartEvent);
+ }
get engine() {
return this.#delegate?.engine ?? null;
}
+ get config(): HlsMediaConfig {
+ return super.config;
+ }
+
+ set config(config: HlsMediaConfig) {
+ super.config = config;
+ if (this.#shouldEngineUpdate(this.#engineConfigKey())) this.#requestLoad();
+ }
+
get error() {
return this.#delegate?.error ?? null;
}
@@ -76,44 +110,6 @@ export class HlsMedia extends HTMLVideoElementHost implements HlsMediaProps {
this.#requestLoad();
}
- /** Explicit source type. When unset, inferred from the source URL extension. */
- get type() {
- return this.#type ?? inferSourceType(this.src);
- }
-
- set type(value: SourceType | undefined) {
- this.#type = value;
- this.#requestLoad();
- }
-
- /** Whether to prefer `'mse'` (hls.js) or `'native'` (browser-built-in) HLS. */
- get preferPlayback() {
- return this.#preferPlayback;
- }
-
- set preferPlayback(value) {
- this.#preferPlayback = value;
- this.#requestLoad();
- }
-
- get config() {
- return this.#config;
- }
-
- set config(config) {
- this.#config = config;
- this.#requestLoad();
- }
-
- get debug() {
- return this.#debug;
- }
-
- set debug(debug) {
- this.#debug = debug;
- this.#requestLoad();
- }
-
/** Preload type (`'none'` / `'metadata'` / `'auto'`). */
get preload() {
return this.#preload;
@@ -142,7 +138,7 @@ export class HlsMedia extends HTMLVideoElementHost implements HlsMediaProps {
if (this.#streamType === value) return;
this.#streamType = value;
- this.dispatchEvent(new Event('streamtypechange'));
+ this.dispatchEvent(new HlsMediaEvent('streamtypechange'));
}
/**
@@ -164,34 +160,23 @@ export class HlsMedia extends HTMLVideoElementHost implements HlsMediaProps {
return this.#delegate?.targetLiveWindow ?? Number.NaN;
}
- attach(target: HTMLVideoElement) {
- super.attach(target);
- this.#delegate?.attach(target);
- }
-
- detach() {
- this.#delegate?.detach();
- super.detach();
- }
-
- destroy() {
- this.detach();
- this.#engineDestroy();
- }
-
- load() {
+ async load() {
this.#loadRequested = null;
- if (this.#shouldEngineUpdate(this.#engineProps())) {
+ if (this.remote.state === 'connected') {
+ this.dispatchEvent(new HlsMediaEvent('loadstart'));
+ return super.load();
+ }
+
+ if (this.#shouldEngineUpdate(this.#engineConfigKey())) {
this.#engineDestroy();
- this.#prevEngineProps = this.#engineProps();
+ this.#prevEngineConfigKey = this.#engineConfigKey();
+ const contentType = this.config.contentType ?? inferContentType(this.#src);
const useMse =
- Hls.isSupported() && this.type === SourceTypes.M3U8 && this.preferPlayback !== PlaybackTypes.NATIVE;
+ Hls.isSupported() && contentType === ContentTypes.M3U8 && this.config.preferPlayback !== PlaybackTypes.NATIVE;
- this.#delegate = useMse
- ? new HlsJsMedia({ config: { ...this.config, debug: this.debug } })
- : new NativeHlsMedia();
+ this.#delegate = useMse ? new HlsJsMedia({ config: { ...this.config?.hlsJs } }) : new NativeHlsMedia();
bridgeEvents(this.#delegate, this);
@@ -203,16 +188,21 @@ export class HlsMedia extends HTMLVideoElementHost implements HlsMediaProps {
this.#delegate.preload = this.preload;
- if (this.target) {
- this.#delegate.attach(this.target);
+ if (this.#mediaElement) {
+ this.#delegate.attach(this.#mediaElement);
}
}
if (this.#delegate) {
+ this.dispatchEvent(new HlsMediaEvent('loadstart'));
this.#delegate.src = this.#src;
}
}
+ #stopTargetLoadStartEvent = (event: Event) => {
+ if (!(event instanceof HlsMediaEvent)) event.stopImmediatePropagation();
+ };
+
async #requestLoad() {
if (this.#loadRequested) return;
await (this.#loadRequested = Promise.resolve());
@@ -220,31 +210,30 @@ export class HlsMedia extends HTMLVideoElementHost implements HlsMediaProps {
this.load();
}
- #shouldEngineUpdate(nextEngineProps: Record) {
- return !shallowEqual(this.#prevEngineProps, nextEngineProps);
+ #shouldEngineUpdate(nextEngineConfigKey: Record) {
+ return !shallowEqual(this.#prevEngineConfigKey, nextEngineConfigKey);
}
- #engineProps() {
+ #engineConfigKey() {
return {
- config: this.config,
- debug: this.debug,
- preferPlayback: this.preferPlayback,
- type: this.type,
+ ...this.config.hlsJs,
+ preferPlayback: this.config.preferPlayback,
+ contentType: this.config.contentType,
};
}
#engineDestroy() {
this.#delegate?.destroy();
this.#delegate = null;
- this.#prevEngineProps = null;
+ this.#prevEngineConfigKey = null;
this.#loadRequested = null;
// Delegate teardown already emits `streamtypechange` (bridged); only sync cache.
if (!this.#isUserStreamType) this.#streamType = StreamTypes.UNKNOWN;
}
}
-function inferSourceType(src: string): SourceType {
+function inferContentType(src: string): SourceType {
const path = src.split(/[?#]/)[0] ?? '';
- if (path.endsWith('.mp4')) return SourceTypes.MP4;
- return SourceTypes.M3U8;
+ if (path.endsWith('.mp4')) return ContentTypes.MP4;
+ return ContentTypes.M3U8;
}
diff --git a/packages/core/src/dom/media/hls/live.ts b/packages/core/src/dom/media/hls/live.ts
index 37712bb0..90e96d84 100644
--- a/packages/core/src/dom/media/hls/live.ts
+++ b/packages/core/src/dom/media/hls/live.ts
@@ -94,7 +94,7 @@ export function HlsJsMediaLiveMixin>(Bas
#armSeekToLive() {
this.#disarmSeekToLive();
- const target = this.target as HTMLMediaElement | null;
+ const target = this.target as HTMLVideoElement | null;
if (!target || target.autoplay) return;
this.#seekToLiveAbort = new AbortController();
@@ -115,7 +115,7 @@ export function HlsJsMediaLiveMixin>(Bas
}
#trySeekToLive() {
- const target = this.target as HTMLMediaElement | null;
+ const target = this.target as HTMLVideoElement | null;
if (!target) return;
const { liveEdgeStart } = this;
if (!Number.isFinite(liveEdgeStart)) return;
diff --git a/packages/core/src/dom/media/hls/metadata-tracks.ts b/packages/core/src/dom/media/hls/metadata-tracks.ts
index 3ce59e1d..27485ab2 100644
--- a/packages/core/src/dom/media/hls/metadata-tracks.ts
+++ b/packages/core/src/dom/media/hls/metadata-tracks.ts
@@ -21,7 +21,8 @@ export function HlsJsMediaMetadataTracksMixin` element.
+ const target = this.target as HTMLVideoElement | null;
if (!target) return;
[...target.textTracks].forEach((track) => {
diff --git a/packages/core/src/dom/media/hls/preload.ts b/packages/core/src/dom/media/hls/preload.ts
index ee3be4b8..948d4703 100644
--- a/packages/core/src/dom/media/hls/preload.ts
+++ b/packages/core/src/dom/media/hls/preload.ts
@@ -45,7 +45,7 @@ export function HlsJsMediaPreloadMixin>(
#init(): void {
this.#preloadAbort?.abort();
- const target = this.target as HTMLMediaElement | null;
+ const target = this.target as HTMLVideoElement | null;
if (!target) return;
// Sync stored preload to the native element (may have been set before attach)
diff --git a/packages/core/src/dom/media/hls/tests/errors.test.ts b/packages/core/src/dom/media/hls/tests/errors.test.ts
index 92aa8a1c..536d1368 100644
--- a/packages/core/src/dom/media/hls/tests/errors.test.ts
+++ b/packages/core/src/dom/media/hls/tests/errors.test.ts
@@ -2,24 +2,16 @@ import Hls from 'hls.js';
import { describe, expect, it, vi } from 'vitest';
import { MediaError } from '../../../../core/media/media-error';
+import { HTMLVideoElementHost } from '../../video-host';
import { HlsJsMediaErrorsMixin } from '../errors';
-class FakeHost extends EventTarget {
+class FakeHost extends HTMLVideoElementHost {
engine: Hls | null;
- target: HTMLMediaElement | null = null;
constructor(engine: Hls | null = null) {
super();
this.engine = engine;
}
-
- attach(target: EventTarget): void {
- this.target = target as HTMLMediaElement;
- }
-
- detach(): void {
- this.target = null;
- }
}
const HlsJsMediaErrors = HlsJsMediaErrorsMixin(FakeHost);
diff --git a/packages/core/src/dom/media/hls/tests/hls-media.test.ts b/packages/core/src/dom/media/hls/tests/hls-media.test.ts
index a9fbbdd0..41a056ad 100644
--- a/packages/core/src/dom/media/hls/tests/hls-media.test.ts
+++ b/packages/core/src/dom/media/hls/tests/hls-media.test.ts
@@ -1,10 +1,13 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MediaError } from '../../../../core/media/media-error';
+import type { RemotePlaybackLike } from '../../../../core/media/types';
+import { addComponent, type Component } from '../../media-host';
import { NativeHlsMedia } from '../../native-hls';
-import { HlsMedia, SourceTypes } from '../index';
+import { ContentTypes, HlsMedia } from '../index';
afterEach(() => {
document.body.innerHTML = '';
+ vi.unstubAllGlobals();
});
function fireDurationChange(video: HTMLVideoElement, duration: number) {
@@ -30,8 +33,7 @@ function setup() {
const handler = vi.fn();
media.addEventListener('error', handler);
- media.preferPlayback = 'native';
- media.type = SourceTypes.M3U8;
+ media.config = { preferPlayback: 'native', contentType: ContentTypes.M3U8 };
media.load();
return { media, video, handler };
@@ -86,8 +88,7 @@ describe('HlsMedia', () => {
const pauseHandler = vi.fn();
media.addEventListener('pause', pauseHandler);
- media.preferPlayback = 'native';
- media.type = SourceTypes.M3U8;
+ media.config = { preferPlayback: 'native', contentType: ContentTypes.M3U8 };
media.load();
video.dispatchEvent(new Event('pause'));
@@ -96,6 +97,127 @@ describe('HlsMedia', () => {
});
});
+ describe('loadstart', () => {
+ it('dispatches loadstart to listeners once per load', () => {
+ const video = document.createElement('video');
+ document.body.appendChild(video);
+
+ const media = new HlsMedia();
+ media.attach(video);
+
+ const handler = vi.fn();
+ media.addEventListener('loadstart', handler);
+
+ media.load();
+
+ expect(handler).toHaveBeenCalledOnce();
+ });
+
+ it('does not forward the native loadstart from the target', () => {
+ const { media, video } = setup();
+
+ const handler = vi.fn();
+ media.addEventListener('loadstart', handler);
+
+ video.dispatchEvent(new Event('loadstart'));
+
+ expect(handler).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('config', () => {
+ it('recreates the engine when a new hlsJs config is assigned', () => {
+ const { media, video } = setup();
+
+ fireDurationChange(video, Infinity);
+ expect(media.streamType).toBe('live');
+
+ const handler = vi.fn();
+ media.addEventListener('streamtypechange', handler);
+
+ // New `hlsJs` option values must recreate the engine to take effect.
+ media.config = { hlsJs: { maxBufferLength: 60 } };
+ media.load();
+
+ // Teardown `live` → `unknown`, then the new delegate re-detects `live`.
+ expect(handler).toHaveBeenCalledTimes(2);
+ expect(media.streamType).toBe('live');
+ });
+
+ it('does not recreate the engine for an equivalent hlsJs config', () => {
+ const { media, video } = setup();
+
+ media.config = { hlsJs: { maxBufferLength: 60 } };
+ media.load();
+
+ fireDurationChange(video, Infinity);
+ const handler = vi.fn();
+ media.addEventListener('streamtypechange', handler);
+
+ // Same option values in a new object (e.g. an inline React prop).
+ media.config = { hlsJs: { maxBufferLength: 60 } };
+ media.load();
+
+ // No engine teardown → no streamType churn.
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it('merges config assignments', () => {
+ const { media } = setup();
+
+ media.config = { hlsJs: { maxBufferLength: 60 } };
+
+ // Keys from the previous assignment in `setup()` survive.
+ expect(media.config.preferPlayback).toBe('native');
+ expect(media.config.hlsJs).toEqual({ maxBufferLength: 60 });
+ });
+ });
+
+ describe('remote playback load', () => {
+ function setupConnected(load: () => Promise) {
+ const video = document.createElement('video');
+ document.body.appendChild(video);
+
+ const media = new HlsMedia();
+ media.attach(video);
+
+ const component: Component = {
+ get targetOverride() {
+ return { remote: { state: 'connected' } as RemotePlaybackLike, load };
+ },
+ };
+ addComponent(media, component);
+
+ return { media };
+ }
+
+ it('awaits the receiver load while connected', async () => {
+ let resolveLoad!: () => void;
+ const load = vi.fn(() => new Promise((resolve) => (resolveLoad = resolve)));
+ const { media } = setupConnected(load);
+
+ let settled = false;
+ const result = media.load().then(() => {
+ settled = true;
+ });
+
+ expect(load).toHaveBeenCalledTimes(1);
+ await Promise.resolve();
+ expect(settled).toBe(false);
+
+ resolveLoad();
+ await result;
+ expect(settled).toBe(true);
+ });
+
+ it('rejects when the receiver load rejects', async () => {
+ const load = vi.fn(() => Promise.reject(new Error('receiver failed')));
+ const { media } = setupConnected(load);
+
+ await expect(media.load()).rejects.toThrow('receiver failed');
+ });
+ });
+
describe('destroy', () => {
it('removes forwarding listeners from the native element', () => {
const { media, video } = setup();
@@ -179,9 +301,9 @@ describe('HlsMedia', () => {
expect(media.streamType).toBe('live');
handler.mockClear();
- // `debug` is part of `HlsMedia`'s engine props — toggling it recreates the
- // native delegate without switching playback engines.
- media.debug = true;
+ // `config.hlsJs.debug` is part of `HlsMedia`'s engine props — toggling it
+ // recreates the native delegate without switching playback engines.
+ media.config = { ...media.config, hlsJs: { debug: true } };
media.load();
// Teardown: a single `live` → `unknown`, then the new delegate re-detects
@@ -203,7 +325,7 @@ describe('HlsMedia', () => {
});
// Recreates the native delegate; duration would otherwise sync-detect as `on-demand`.
- media.debug = true;
+ media.config = { ...media.config, hlsJs: { debug: true } };
media.load();
expect(seen).not.toContain('on-demand');
@@ -272,7 +394,7 @@ describe('HlsMedia', () => {
media.streamType = 'live';
expect(media.streamType).toBe('live');
- media.preferPlayback = 'mse';
+ media.config = { ...media.config, preferPlayback: 'mse' };
media.load();
expect(media.streamType).toBe('live');
@@ -284,7 +406,7 @@ describe('HlsMedia', () => {
media.streamType = 'live';
media.streamType = 'unknown';
- media.preferPlayback = 'mse';
+ media.config = { ...media.config, preferPlayback: 'mse' };
media.load();
expect(media.streamType).toBe('unknown');
diff --git a/packages/core/src/dom/media/hls/tests/live.test.ts b/packages/core/src/dom/media/hls/tests/live.test.ts
index d52a8ac5..8034b012 100644
--- a/packages/core/src/dom/media/hls/tests/live.test.ts
+++ b/packages/core/src/dom/media/hls/tests/live.test.ts
@@ -1,8 +1,8 @@
import Hls from 'hls.js';
import { describe, expect, it, vi } from 'vitest';
+import { HTMLVideoElementHost } from '../../video-host';
import { HlsJsMediaLiveMixin } from '../live';
-import type { HlsEngineHost } from '../types';
function createEngine(userConfig: Record = {}): Hls {
const listeners = new Map void>>();
@@ -22,9 +22,8 @@ function createEngine(userConfig: Record = {}): Hls {
} as unknown as Hls;
}
-class FakeHost extends EventTarget implements HlsEngineHost {
+class FakeHost extends HTMLVideoElementHost {
engine: Hls | null;
- target: HTMLMediaElement | null = null;
constructor(engine: Hls | null = null) {
super();
@@ -53,7 +52,7 @@ function emitLevelLoaded(engine: Hls, details: unknown) {
(engine as any).emit(Hls.Events.LEVEL_LOADED, { details });
}
-function setTargetSeekable(host: { target: HTMLMediaElement | null }, ranges: [number, number][]) {
+function setTargetSeekable(host: FakeHost, ranges: [number, number][]) {
const video = document.createElement('video');
Object.defineProperty(video, 'seekable', {
configurable: true,
@@ -65,7 +64,7 @@ function setTargetSeekable(host: { target: HTMLMediaElement | null }, ranges: [n
} as TimeRanges;
},
});
- host.target = video;
+ host.attach(video);
return video;
}
@@ -203,7 +202,7 @@ describe('HlsJsMediaLiveMixin', () => {
} as TimeRanges;
},
});
- host.target = video;
+ host.attach(video);
emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 18 }));
diff --git a/packages/core/src/dom/media/hls/tests/preload.test.ts b/packages/core/src/dom/media/hls/tests/preload.test.ts
index d129c842..86c067d0 100644
--- a/packages/core/src/dom/media/hls/tests/preload.test.ts
+++ b/packages/core/src/dom/media/hls/tests/preload.test.ts
@@ -1,8 +1,8 @@
import Hls from 'hls.js';
import { describe, expect, it, vi } from 'vitest';
+import { HTMLVideoElementHost } from '../../video-host';
import { HlsJsMediaPreloadMixin } from '../preload';
-import type { HlsEngineHost } from '../types';
function createEngine(): Hls {
const listeners = new Map void>>();
@@ -26,14 +26,18 @@ function createEngine(): Hls {
} as unknown as Hls;
}
-class FakeHost extends EventTarget implements HlsEngineHost {
+class FakeHost extends HTMLVideoElementHost {
engine: Hls | null;
- target: HTMLMediaElement | null = null;
constructor(engine: Hls | null = null) {
super();
this.engine = engine;
}
+
+ // Re-expose the now-protected `target` for test assertions.
+ override get target(): HTMLVideoElement | null {
+ return super.target as HTMLVideoElement | null;
+ }
}
const PreloadHost = HlsJsMediaPreloadMixin(FakeHost);
@@ -62,7 +66,7 @@ describe('HlsJsMediaPreloadMixin', () => {
expect(host.target).toBeNull();
const video = document.createElement('video');
- host.target = video;
+ host.attach(video);
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
expect(video.preload).toBe('none');
@@ -75,7 +79,7 @@ describe('HlsJsMediaPreloadMixin', () => {
host.preload = 'auto';
const video = document.createElement('video');
- host.target = video;
+ host.attach(video);
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
expect(engine.startLoad).toHaveBeenCalled();
@@ -89,7 +93,7 @@ describe('HlsJsMediaPreloadMixin', () => {
host.preload = 'none';
const video = document.createElement('video');
- host.target = video;
+ host.attach(video);
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
expect(engine.startLoad).not.toHaveBeenCalled();
@@ -102,7 +106,7 @@ describe('HlsJsMediaPreloadMixin', () => {
host.preload = 'metadata';
const video = document.createElement('video');
- host.target = video;
+ host.attach(video);
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
expect(engine.startLoad).toHaveBeenCalled();
@@ -117,7 +121,7 @@ describe('HlsJsMediaPreloadMixin', () => {
host.preload = 'metadata';
const video = document.createElement('video');
- host.target = video;
+ host.attach(video);
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
(engine.startLoad as ReturnType).mockClear();
@@ -134,7 +138,7 @@ describe('HlsJsMediaPreloadMixin', () => {
const host = new PreloadHost(engine);
const video = document.createElement('video');
- host.target = video;
+ host.attach(video);
host.preload = 'auto';
@@ -148,7 +152,7 @@ describe('HlsJsMediaPreloadMixin', () => {
host.preload = 'metadata';
const video = document.createElement('video');
- host.target = video;
+ host.attach(video);
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
(engine.startLoad as ReturnType).mockClear();
diff --git a/packages/core/src/dom/media/hls/text-tracks.ts b/packages/core/src/dom/media/hls/text-tracks.ts
index 0fbe5548..39eb9071 100644
--- a/packages/core/src/dom/media/hls/text-tracks.ts
+++ b/packages/core/src/dom/media/hls/text-tracks.ts
@@ -40,7 +40,8 @@ export function HlsJsMediaTextTracksMixin` element.
+ const media = this.target as HTMLVideoElement;
const onTracksFound = (_event: string, data: NonNativeTextTracksData) => {
this.#clearTracks();
diff --git a/packages/core/src/dom/media/hls/types.ts b/packages/core/src/dom/media/hls/types.ts
index 80133572..e36a6335 100644
--- a/packages/core/src/dom/media/hls/types.ts
+++ b/packages/core/src/dom/media/hls/types.ts
@@ -1,8 +1,8 @@
import type Hls from 'hls.js';
+import type { HTMLVideoElementHost } from '../video-host';
export type HlsPlaylistTypes = 'VOD' | 'EVENT' | null | undefined;
-export interface HlsEngineHost extends EventTarget {
+export type HlsEngineHost = HTMLVideoElementHost & {
readonly engine?: Hls | null;
- readonly target?: HTMLMediaElement | null;
-}
+};
diff --git a/packages/core/src/dom/media/media-host.ts b/packages/core/src/dom/media/media-host.ts
index 06dbc0d7..e7541b3e 100644
--- a/packages/core/src/dom/media/media-host.ts
+++ b/packages/core/src/dom/media/media-host.ts
@@ -1,91 +1,125 @@
-import type { ErrorLike, EventLike, EventTargetLike } from '../../core/media/types';
+import type { EventListenerFor, EventType, QueriedElement } from '@videojs/utils/dom';
+import {
+ type EventLike,
+ type MediaFull,
+ type MediaStreamType,
+ MediaStreamTypes,
+ type MediaTargetLike,
+ type TextTrackKind,
+ type TextTrackLike,
+} from '../../core/media/types';
+import { EMPTY_REMOTE, EMPTY_TEXT_TRACKS, EMPTY_TIME_RANGES } from './constants';
+import { getComponents, getProp, setProp } from './utils';
-const EMPTY_TIME_RANGES: Readonly = Object.freeze({
- length: 0,
- start() {
- return 0;
- },
- end() {
- return 0;
- },
-} as TimeRanges);
+export { addComponent, getComponents, getOwner, getProp, setProp } from './utils';
-export class HTMLMediaElementHost
+export interface HTMLMediaTargetLike extends MediaTargetLike, EventTarget {
+ querySelector(selectors: string): E | null;
+ querySelectorAll(selectors: string): NodeListOf | never[];
+}
+
+export interface Component {
+ readonly targetOverride?: Partial | null;
+ setMedia?(host: HTMLMediaElementHost): void;
+ attach?(target: Target): void;
+ detach?(): void;
+ destroy?(): void;
+}
+
+export interface ComponentConstructor {
+ new (...args: any[]): T;
+ readonly configKey?: string;
+}
+
+export interface Components extends Map {
+ get(component: ComponentConstructor): T | undefined;
+ set(component: ComponentConstructor, 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;
+
+export class HTMLMediaElementHost
extends EventTarget
- implements EventTargetLike
+ implements MediaFull
{
- #target: T | null = null;
- #types = new Set();
+ #target: Target | null = null;
+ #eventTypes = new Set();
+ #streamType: MediaStreamType = MediaStreamTypes.UNKNOWN;
+ #config: MediaConfig = {};
- get target() {
+ protected get target() {
return this.#target;
}
- attach(target: T): void {
+ attach(target: Target) {
if (!target || this.#target === target) return;
this.#target = target;
- for (const type of this.#types) {
+
+ for (const type of this.#eventTypes) {
target.addEventListener(type, this.#forwardEvent);
}
+
+ for (const component of getComponents(this).values()) {
+ component.attach?.(target);
+ }
}
- detach(): void {
+ detach() {
if (!this.#target) return;
- for (const type of this.#types) {
+
+ for (const component of getComponents(this).values()) {
+ component.detach?.();
+ }
+
+ for (const type of this.#eventTypes) {
this.#target.removeEventListener(type, this.#forwardEvent);
}
+
this.#target = null;
}
- querySelectorAll(selectors: K): NodeListOf | never[];
- querySelectorAll(selectors: string): NodeListOf | never[];
- querySelectorAll(selectors: string): NodeListOf | never[] {
- return this.target?.querySelectorAll(selectors) ?? [];
+ destroy() {
+ this.detach();
+ this.#eventTypes.clear();
+
+ const components = getComponents(this);
+ for (const component of components.values()) {
+ component.destroy?.();
+ const { configKey } = component.constructor as ComponentConstructor;
+ if (configKey) delete this.#config[configKey];
+ }
+ components.clear();
}
- querySelector(selectors: K): HTMLElementTagNameMap[K] | null;
- querySelector(selectors: string): E | null;
- querySelector(selectors: string): Element | null {
- return this.target?.querySelector(selectors) ?? null;
+ querySelectorAll(selectors: S) {
+ return (this.target?.querySelectorAll(selectors) ?? []) as NodeListOf> | never[];
}
- addEventListener(
+ querySelector(selectors: S) {
+ return (this.target?.querySelector(selectors) ?? null) as QueriedElement | null;
+ }
+
+ addEventListener>(
type: K,
- listener: (event: Events[K]) => void,
+ listener: EventListenerFor,
options?: boolean | AddEventListenerOptions
- ): void;
- addEventListener(
- type: string,
- listener: EventListenerOrEventListenerObject | null,
- options?: boolean | AddEventListenerOptions
- ): void;
- addEventListener(
- type: string,
- listener: EventListenerOrEventListenerObject | ((event: never) => void) | null,
- options?: boolean | AddEventListenerOptions
- ): void {
- if (!this.#types.has(type)) {
- this.#types.add(type);
+ ) {
+ if (!this.#eventTypes.has(type)) {
+ this.#eventTypes.add(type);
this.target?.addEventListener(type, this.#forwardEvent);
}
super.addEventListener(type, listener as EventListener, options);
}
- removeEventListener(
+ removeEventListener>(
type: K,
- listener: (event: Events[K]) => void,
+ listener: EventListenerFor,
options?: boolean | EventListenerOptions
- ): void;
- removeEventListener(
- type: string,
- listener: EventListenerOrEventListenerObject | null,
- options?: boolean | EventListenerOptions
- ): void;
- removeEventListener(
- type: string,
- listener: EventListenerOrEventListenerObject | ((event: never) => void) | null,
- options?: boolean | EventListenerOptions
- ): void {
+ ) {
super.removeEventListener(type, listener as EventListener, options);
}
@@ -93,143 +127,195 @@ export class HTMLMediaElementHost {
- readonly debug: boolean;
- attach(target: HTMLMediaElement): void;
- detach(): void;
- load(): void;
+export interface MuxDataMedia extends EventTarget {
+ readonly engine?: HlsMedia['engine'];
+ readonly src: string;
}
-export const MuxDataMediaMixin: Mixin = (BaseClass) => {
- class MuxDataMedia extends BaseClass {
- #MuxDataSdk: MuxDataSdk | undefined = Mux;
- #MuxDataSdkInitializedBefore = false;
- #beaconCollectionDomain: string | undefined;
- #disableCookies = false;
- #metadata: MuxDataOptions['data'] | undefined;
- #envKey: string | undefined;
- #playerSoftwareName: string | undefined = (this.constructor as { PLAYER_SOFTWARE_NAME?: string })
- .PLAYER_SOFTWARE_NAME;
- #playerSoftwareVersion: string | undefined = getPlayerVersion();
- #playerInitTime: number | undefined = this.#generatePlayerInitTime();
+declare module '../media-host' {
+ interface MediaComponentConfig {
+ muxData: Partial;
+ }
+}
- get MuxDataSdk() {
- return this.#MuxDataSdk;
- }
+export class MuxData implements MuxDataProps {
+ static readonly configKey = 'muxData';
- set MuxDataSdk(value) {
- this.#MuxDataSdk = value;
- }
+ #MuxDataSdk: MuxDataSdk | undefined = Mux;
+ #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();
+ #playerInitTime: number | undefined = this.#generatePlayerInitTime();
+ #media: MuxDataMedia | null = null;
+ #target: HTMLVideoElement | null = null;
- get beaconCollectionDomain() {
- return this.#beaconCollectionDomain;
- }
-
- set beaconCollectionDomain(value) {
- this.#beaconCollectionDomain = value;
- }
-
- get disableCookies() {
- return this.#disableCookies;
- }
-
- set disableCookies(value) {
- this.#disableCookies = value;
- }
-
- get envKey() {
- return this.#envKey;
- }
-
- set envKey(value) {
- this.#envKey = value;
- }
-
- get playerSoftwareName() {
- return this.#playerSoftwareName;
- }
-
- set playerSoftwareName(value) {
- this.#playerSoftwareName = value;
- }
-
- get playerSoftwareVersion() {
- return this.#playerSoftwareVersion;
- }
-
- set playerSoftwareVersion(value) {
- this.#playerSoftwareVersion = value;
- }
-
- get playerInitTime() {
- return this.#playerInitTime;
- }
-
- set playerInitTime(value) {
- this.#playerInitTime = value;
- }
-
- get metadata() {
- return this.#metadata;
- }
-
- set metadata(value) {
- this.#metadata = value;
- }
-
- attach(target: HTMLMediaElement) {
- super.attach(target);
-
- // Only initialize Mux Data SDK if it was already initialized before in attach;
- // the first initializeMuxDataSdk call should be done in the deferred load hook
- // so all the properties are set before the Mux Data SDK is initialized.
- if (this.#MuxDataSdkInitializedBefore) {
- this.#initializeMuxDataSdk();
- }
- }
-
- detach() {
- if (this.target?.mux) {
- this.target.mux.destroy();
- delete this.target.mux;
- }
- super.detach();
- }
-
- load() {
- super.load();
- this.#initializeMuxDataSdk();
- }
-
- #initializeMuxDataSdk() {
- const target = this.target as HTMLMediaElement;
-
- if (!this.MuxDataSdk || !target || (target.mux && !target.mux.deleted)) return;
-
- this.#MuxDataSdkInitializedBefore = true;
-
- const {
- debug,
- beaconCollectionDomain,
- disableCookies,
- engine: hlsjs,
- envKey: env_key,
- playerSoftwareName: player_software_name,
- playerSoftwareVersion: player_software_version,
- playerInitTime: player_init_time,
- metadata = {},
- } = this;
-
- const { view_session_id = this.MuxDataSdk?.utils.generateUUID() } = metadata;
- const video_id = toVideoId(this as unknown as MuxVideoIdProps);
- metadata.view_session_id = view_session_id;
- if (video_id) metadata.video_id = video_id;
-
- this.MuxDataSdk?.monitor(target, {
- debug,
- ...(beaconCollectionDomain ? { beaconCollectionDomain } : {}),
- ...(disableCookies ? { disableCookies } : {}),
- ...(hlsjs ? { hlsjs } : {}),
- Hls,
- data: {
- ...(env_key ? { env_key } : {}),
- ...(player_software_name ? { player_software_name } : {}),
- // NOTE: Adding this because there appears to be some instability on whether
- // player_software_name or player_software "wins" for Mux Data (CJP)
- ...(player_software_name ? { player_software: player_software_name } : {}),
- ...(player_software_version ? { player_software_version } : {}),
- ...(player_init_time ? { player_init_time } : {}),
- // Use any metadata passed in programmatically (which may override the defaults above)
- ...metadata,
- },
- });
- }
-
- #generatePlayerInitTime() {
- if (!this.MuxDataSdk) return undefined;
- return this.MuxDataSdk.utils.now();
- }
+ constructor(props: Partial = {}) {
+ Object.assign(this, props);
}
- return MuxDataMedia as any;
-};
+ setMedia(media: MuxDataMedia) {
+ this.#media = media;
+ this.#media.addEventListener('loadstart', this.#reinitialize);
+ }
+
+ attach(target: HTMLVideoElement) {
+ this.#target = target;
+ this.#reinitialize();
+ }
+
+ detach() {
+ if (this.#target?.mux) {
+ this.#target.mux.destroy();
+ delete this.#target.mux;
+ }
+ this.#target = null;
+ }
+
+ destroy() {
+ this.#media?.removeEventListener('loadstart', this.#reinitialize);
+ this.#media = null;
+ this.#target = null;
+ }
+
+ get MuxDataSdk() {
+ return this.#MuxDataSdk;
+ }
+
+ set MuxDataSdk(value) {
+ if (this.#MuxDataSdk === value) return;
+ this.#MuxDataSdk = value;
+ this.#reinitialize();
+ }
+
+ get beaconCollectionDomain() {
+ return this.#beaconCollectionDomain;
+ }
+
+ set beaconCollectionDomain(value) {
+ if (this.#beaconCollectionDomain === value) return;
+ this.#beaconCollectionDomain = value;
+ this.#reinitialize();
+ }
+
+ get debug() {
+ return this.#debug;
+ }
+
+ set debug(value) {
+ if (this.#debug === value) return;
+ this.#debug = value;
+ this.#reinitialize();
+ }
+
+ get disableCookies() {
+ return this.#disableCookies;
+ }
+
+ set disableCookies(value) {
+ if (this.#disableCookies === value) return;
+ this.#disableCookies = value;
+ this.#reinitialize();
+ }
+
+ get envKey() {
+ return this.#envKey;
+ }
+
+ set envKey(value) {
+ if (this.#envKey === value) return;
+ this.#envKey = value;
+ this.#target?.mux?.updateData(value ? { env_key: value } : {});
+ }
+
+ get playerSoftwareName() {
+ return this.#playerSoftwareName;
+ }
+
+ set playerSoftwareName(value) {
+ if (this.#playerSoftwareName === value) return;
+ this.#playerSoftwareName = value;
+ this.#target?.mux?.updateData(value ? { player_software_name: value } : {});
+ }
+
+ get playerSoftwareVersion() {
+ return this.#playerSoftwareVersion;
+ }
+
+ set playerSoftwareVersion(value) {
+ if (this.#playerSoftwareVersion === value) return;
+ this.#playerSoftwareVersion = value;
+ this.#target?.mux?.updateData(value ? { player_software_version: value } : {});
+ }
+
+ get playerInitTime() {
+ return this.#playerInitTime;
+ }
+
+ set playerInitTime(value) {
+ if (this.#playerInitTime === value) return;
+ this.#playerInitTime = value;
+ this.#target?.mux?.updateData(value ? { player_init_time: value } : {});
+ }
+
+ get metadata() {
+ return this.#metadata;
+ }
+
+ set metadata(value) {
+ if (this.#metadata === value) return;
+ this.#metadata = value;
+ this.#target?.mux?.updateData(value ? { ...value } : {});
+ }
+
+ #reinitialize = () => {
+ if (this.#target?.mux) {
+ this.#target.mux.destroy();
+ delete this.#target.mux;
+ }
+ this.#initialize();
+ };
+
+ async #initialize() {
+ // Defer to ensure all properties are set before the Mux Data SDK is initialized.
+ if (this.#pendingInitialize) return;
+ await (this.#pendingInitialize = Promise.resolve());
+ this.#pendingInitialize = null;
+
+ const target = this.#target;
+ const media = this.#media;
+
+ if (!this.MuxDataSdk || !target || !media || (target.mux && !target.mux.deleted)) return;
+
+ const {
+ debug,
+ beaconCollectionDomain,
+ disableCookies,
+ envKey: env_key,
+ playerSoftwareName: player_software_name,
+ playerSoftwareVersion: player_software_version,
+ playerInitTime: player_init_time,
+ metadata = {},
+ } = this;
+ const { engine: hlsjs } = media;
+
+ const { view_session_id = this.MuxDataSdk?.utils.generateUUID() } = metadata;
+ const video_id = toVideoId({ metadata, src: media.src });
+ metadata.view_session_id = view_session_id;
+ if (video_id) metadata.video_id = video_id;
+
+ this.MuxDataSdk?.monitor(target, {
+ debug,
+ ...(beaconCollectionDomain ? { beaconCollectionDomain } : {}),
+ ...(disableCookies ? { disableCookies } : {}),
+ ...(hlsjs ? { hlsjs } : {}),
+ Hls,
+ data: {
+ ...(env_key ? { env_key } : {}),
+ ...(player_software_name ? { player_software_name } : {}),
+ // NOTE: Adding this because there appears to be some instability on whether
+ // player_software_name or player_software "wins" for Mux Data (CJP)
+ ...(player_software_name ? { player_software: player_software_name } : {}),
+ ...(player_software_version ? { player_software_version } : {}),
+ ...(player_init_time ? { player_init_time } : {}),
+ // Use any metadata passed in programmatically (which may override the defaults above)
+ ...metadata,
+ },
+ });
+ }
+
+ #generatePlayerInitTime() {
+ if (!this.MuxDataSdk) return undefined;
+ return this.MuxDataSdk.utils.now();
+ }
+}
export type MuxVideoIdProps = {
src: string;
diff --git a/packages/core/src/dom/media/mux/tests/index.test.ts b/packages/core/src/dom/media/mux/tests/index.test.ts
index 41b56ccd..482f880b 100644
--- a/packages/core/src/dom/media/mux/tests/index.test.ts
+++ b/packages/core/src/dom/media/mux/tests/index.test.ts
@@ -1,22 +1,131 @@
-import { describe, expect, it } from 'vitest';
-import { MuxVideoMedia } from '..';
+import { describe, expect, it, vi } from 'vitest';
+import { HlsMedia } from '../../hls';
+import { addComponent } from '../../media-host';
+import { MuxData } from '..';
+import type { MuxDataSdk } from '../types';
-describe('MuxVideoMedia', () => {
- it('accepts src directly', () => {
- const media = new MuxVideoMedia();
+function createSdk() {
+ const monitor = vi.fn();
+ const sdk = {
+ monitor,
+ utils: { now: () => 0, generateUUID: () => 'uuid' },
+ } as unknown as MuxDataSdk;
+ return { sdk, monitor };
+}
+
+class FakeMedia extends EventTarget {
+ engine: HlsMedia['engine'] = null;
+ src = '';
+}
+
+// Initialization is deferred by a microtask so all props settle first.
+async function settle() {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+describe('MuxData', () => {
+ it('accepts a player software name', () => {
+ expect(new MuxData({ playerSoftwareName: 'mux-video' }).playerSoftwareName).toBe('mux-video');
+ });
+
+ it('monitors the attached target with the configured data', async () => {
+ const { sdk, monitor } = createSdk();
+ const data = new MuxData({ MuxDataSdk: sdk, envKey: 'key', playerSoftwareName: 'mux-video' });
+ const video = document.createElement('video');
+ const media = new FakeMedia();
media.src = 'https://stream.mux.com/abc123.m3u8';
- expect(media.src).toBe('https://stream.mux.com/abc123.m3u8');
+ data.setMedia(media);
+ data.attach(video);
+
+ await settle();
+
+ expect(monitor).toHaveBeenCalledWith(
+ video,
+ expect.objectContaining({
+ data: expect.objectContaining({ env_key: 'key', player_software_name: 'mux-video' }),
+ })
+ );
});
- it('accepts non-Mux src', () => {
- const media = new MuxVideoMedia();
- media.src = 'https://example.com/video.m3u8';
+ it('does not monitor before a target is attached', async () => {
+ const { sdk, monitor } = createSdk();
+ const data = new MuxData({ MuxDataSdk: sdk });
+ const media = new FakeMedia();
+ media.src = 'https://stream.mux.com/abc123.m3u8';
- expect(media.src).toBe('https://example.com/video.m3u8');
+ data.setMedia(media);
+
+ await settle();
+
+ expect(monitor).not.toHaveBeenCalled();
});
- it('defaults PLAYER_SOFTWARE_NAME to mux-video', () => {
- expect(MuxVideoMedia.PLAYER_SOFTWARE_NAME).toBe('mux-video');
+ it('re-monitors with the new engine when the media fires loadstart', async () => {
+ const { sdk, monitor } = createSdk();
+ const data = new MuxData({ MuxDataSdk: sdk, envKey: 'key' });
+ const video = document.createElement('video');
+ const media = new FakeMedia();
+
+ data.setMedia(media);
+ data.attach(video);
+
+ await settle();
+
+ expect(monitor).toHaveBeenCalledTimes(1);
+
+ const engine = {} as NonNullable;
+ media.engine = engine;
+ media.src = 'https://stream.mux.com/abc123.m3u8';
+ media.dispatchEvent(new Event('loadstart'));
+
+ await settle();
+
+ expect(monitor).toHaveBeenCalledTimes(2);
+ expect(monitor).toHaveBeenLastCalledWith(
+ video,
+ expect.objectContaining({
+ hlsjs: engine,
+ data: expect.objectContaining({ video_id: 'abc123' }),
+ })
+ );
+ });
+
+ it('exposes mux config under host.config.muxData with inferred types', () => {
+ const media = new HlsMedia();
+ addComponent(media, new MuxData());
+
+ // Type-level: `config.muxData` infers `Partial` via the
+ // component's `configKey` augmentation, so these assignments/reads are
+ // checked. This line fails to compile if inference regresses.
+ media.config.muxData = { envKey: 'key', debug: true };
+ const envKey: string | undefined = media.config.muxData?.envKey;
+
+ expect(envKey).toBe('key');
+ // Live binding: the write reached the component instance.
+ expect(media.config.muxData).toBeInstanceOf(MuxData);
+ });
+
+ it('stops re-monitoring after destroy', async () => {
+ const { sdk, monitor } = createSdk();
+ const data = new MuxData({ MuxDataSdk: sdk, envKey: 'key' });
+ const video = document.createElement('video');
+ const media = new FakeMedia();
+ media.src = 'https://stream.mux.com/abc123.m3u8';
+
+ data.setMedia(media);
+ data.attach(video);
+
+ await settle();
+
+ expect(monitor).toHaveBeenCalledTimes(1);
+
+ data.destroy();
+ media.dispatchEvent(new Event('loadstart'));
+
+ await settle();
+
+ expect(monitor).toHaveBeenCalledTimes(1);
});
});
diff --git a/packages/core/src/dom/media/native-hls/errors.ts b/packages/core/src/dom/media/native-hls/errors.ts
index 49139085..df59e385 100644
--- a/packages/core/src/dom/media/native-hls/errors.ts
+++ b/packages/core/src/dom/media/native-hls/errors.ts
@@ -1,13 +1,9 @@
import type { Constructor } from '@videojs/utils/types';
import { MediaError } from '../../../core/media/media-error';
+import type { HTMLVideoElementHost } from '../video-host';
-export interface NativeMediaHost extends EventTarget {
- readonly target: EventTarget | null;
- attach?(target: EventTarget): void;
- detach?(): void;
- destroy?(): void;
-}
+export type NativeMediaHost = HTMLVideoElementHost;
export function NativeHlsMediaErrorsMixin>(BaseClass: Base) {
class NativeHlsMediaErrors extends (BaseClass as Constructor) {
@@ -18,9 +14,9 @@ export function NativeHlsMediaErrorsMixin= 1;
}
export function isMediaPauseCapable(value: unknown): value is MediaPauseCapability {
- return (
- isObject(value) && 'paused' in value && 'ended' in value && isFunction((value as Record).pause)
- );
+ if (!isObject(value)) return false;
+ const media = value as Record;
+ return !isUndefined(media.paused) && !isUndefined(media.ended) && isFunction(media.pause);
}
export function isMediaSeekCapable(value: unknown): value is MediaSeekCapability {
- return isObject(value) && 'currentTime' in value && 'duration' in value && 'seeking' in value;
+ if (!isObject(value)) return false;
+ const media = value as Record;
+ return !isUndefined(media.currentTime) && !isUndefined(media.duration) && !isUndefined(media.seeking);
}
export function isMediaSourceCapable(value: unknown): value is MediaSourceCapability {
+ if (!isObject(value)) return false;
+ const media = value as Record;
return (
- isObject(value) &&
- 'src' in value &&
- 'currentSrc' in value &&
- 'readyState' in value &&
- isFunction((value as Record).load)
+ !isUndefined(media.src) &&
+ !isUndefined(media.currentSrc) &&
+ !isUndefined(media.readyState) &&
+ isFunction(media.load)
);
}
export function isMediaVolumeCapable(value: unknown): value is MediaVolumeCapability {
- return isObject(value) && 'volume' in value && 'muted' in value;
+ if (!isObject(value)) return false;
+ const media = value as Record;
+ return !isUndefined(media.volume) && !isUndefined(media.muted);
}
export function isMediaPlaybackRateCapable(value: unknown): value is MediaPlaybackRateCapability {
- return isObject(value) && 'playbackRate' in value;
+ if (!isObject(value)) return false;
+ const media = value as Record;
+ return !isUndefined(media.playbackRate);
}
export function isMediaBufferCapable(value: unknown): value is MediaBufferCapability {
- return isObject(value) && 'buffered' in value && 'seekable' in value;
+ if (!isObject(value)) return false;
+ const media = value as Record;
+ return (
+ !isUndefined(media.buffered) &&
+ media.buffered !== EMPTY_TIME_RANGES &&
+ !isUndefined(media.seekable) &&
+ media.seekable !== EMPTY_TIME_RANGES
+ );
}
export function isMediaErrorCapable(value: unknown): value is MediaErrorCapability {
- return isObject(value) && 'error' in value;
+ if (!isObject(value)) return false;
+ const media = value as Record;
+ return !isUndefined(media.error);
}
export function isMediaTextTrackCapable(value: unknown): value is MediaTextTrackCapability {
- return isObject(value) && 'textTracks' in value;
+ if (!isObject(value)) return false;
+ const media = value as Record;
+ return !isUndefined(media.textTracks) && media.textTracks !== EMPTY_TEXT_TRACKS;
}
export function isMediaRemotePlaybackCapable(value: unknown): value is MediaRemotePlaybackCapability {
- return isObject(value) && 'remote' in value && isObject((value as Record).remote);
+ if (!isObject(value)) return false;
+ const media = value as Record;
+ return isObject(media.remote) && media.remote !== EMPTY_REMOTE;
}
export function isMediaStreamTypeCapable(value: unknown): value is MediaStreamTypeCapability {
- return isObject(value) && 'streamType' in value;
+ if (!isObject(value)) return false;
+ const media = value as Record;
+ return !isUndefined(media.streamType);
}
export function isMediaLiveCapable(value: unknown): value is MediaLiveCapability {
- return isObject(value) && 'liveEdgeStart' in value && 'targetLiveWindow' in value;
+ if (!isObject(value)) return false;
+ const media = value as Record;
+ return !isUndefined(media.liveEdgeStart) && !isUndefined(media.targetLiveWindow);
}
export function isQuerySelectorAllCapable(
diff --git a/packages/core/src/dom/media/tests/constants.test.ts b/packages/core/src/dom/media/tests/constants.test.ts
new file mode 100644
index 00000000..cc06e553
--- /dev/null
+++ b/packages/core/src/dom/media/tests/constants.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from 'vitest';
+import { EMPTY_TEXT_TRACKS } from '../constants';
+
+describe('EMPTY_TEXT_TRACKS', () => {
+ it('iterates as an empty list', () => {
+ expect([...EMPTY_TEXT_TRACKS]).toEqual([]);
+ expect(Array.from(EMPTY_TEXT_TRACKS)).toEqual([]);
+ });
+
+ it('returns null from getTrackById', () => {
+ expect(EMPTY_TEXT_TRACKS.getTrackById?.('missing')).toBeNull();
+ });
+});
diff --git a/packages/core/src/dom/media/tests/media-host.test.ts b/packages/core/src/dom/media/tests/media-host.test.ts
new file mode 100644
index 00000000..d5fa5f9f
--- /dev/null
+++ b/packages/core/src/dom/media/tests/media-host.test.ts
@@ -0,0 +1,287 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { HTMLAudioElementHost } from '../audio-host';
+import { addComponent, type Component } from '../media-host';
+
+afterEach(() => {
+ document.body.innerHTML = '';
+});
+
+class MutedOverride implements Component {
+ get targetOverride() {
+ return { muted: true };
+ }
+}
+
+class VolumeOverride implements Component {
+ get targetOverride() {
+ return { volume: 0.5 };
+ }
+}
+
+class AttachTracking implements Component {
+ attach = vi.fn();
+ destroy = vi.fn();
+}
+
+class CastLikeOverride implements Component {
+ readonly api = {
+ muted: false,
+ playCount: 0,
+ play() {
+ this.playCount++;
+ return Promise.resolve();
+ },
+ };
+
+ get targetOverride() {
+ return this.api;
+ }
+}
+
+class ConfigurableComponent implements Component {
+ static readonly configKey = 'fake';
+ value = 0;
+ label = '';
+ destroy() {}
+}
+
+describe('HTMLMediaElementHost', () => {
+ describe('component overrides', () => {
+ it('returns the override value when a component exposes the property', () => {
+ const host = new HTMLAudioElementHost();
+ const audio = document.createElement('audio');
+ audio.muted = false;
+ host.attach(audio);
+
+ addComponent(host, new MutedOverride());
+
+ expect(host.muted).toBe(true);
+ });
+
+ it('falls through to the target when the override lacks the property', () => {
+ const host = new HTMLAudioElementHost();
+ const audio = document.createElement('audio');
+ audio.defaultMuted = true;
+ host.attach(audio);
+
+ addComponent(host, new MutedOverride());
+
+ // `defaultMuted` isn't overridden, so it reads from the target.
+ expect(host.defaultMuted).toBe(true);
+ });
+
+ it('falls through to the target when no component overrides the property', () => {
+ const host = new HTMLAudioElementHost();
+ const audio = document.createElement('audio');
+ audio.muted = true;
+ host.attach(audio);
+
+ expect(host.muted).toBe(true);
+ });
+
+ it('falls through to the target for properties the override does not own', () => {
+ const host = new HTMLAudioElementHost();
+ const audio = document.createElement('audio');
+ audio.muted = true;
+ host.attach(audio);
+
+ addComponent(host, new VolumeOverride());
+
+ expect(host.volume).toBe(0.5);
+ expect(host.muted).toBe(true);
+ });
+
+ it('falls back to the default when nothing is attached', () => {
+ const host = new HTMLAudioElementHost();
+ expect(host.paused).toBe(true);
+ expect(host.muted).toBe(false);
+ });
+
+ it('writes setter values to the override when it owns the property', () => {
+ const host = new HTMLAudioElementHost();
+ const audio = document.createElement('audio');
+ audio.muted = false;
+ host.attach(audio);
+
+ const component = new CastLikeOverride();
+ addComponent(host, component);
+
+ host.muted = true;
+
+ expect(component.api.muted).toBe(true);
+ expect(audio.muted).toBe(false);
+ });
+
+ it('writes setter values to the target when no override owns the property', () => {
+ const host = new HTMLAudioElementHost();
+ const audio = document.createElement('audio');
+ host.attach(audio);
+
+ host.muted = true;
+
+ expect(audio.muted).toBe(true);
+ });
+
+ it('attaches a late-added component to the current target', () => {
+ const host = new HTMLAudioElementHost();
+ const audio = document.createElement('audio');
+ host.attach(audio);
+
+ const component = new AttachTracking();
+ addComponent(host, component);
+
+ expect(component.attach).toHaveBeenCalledWith(audio);
+ });
+
+ it('does not attach an added component when no target is attached', () => {
+ const host = new HTMLAudioElementHost();
+
+ const component = new AttachTracking();
+ addComponent(host, component);
+
+ expect(component.attach).not.toHaveBeenCalled();
+ });
+
+ it('destroys and unregisters components on destroy', () => {
+ const host = new HTMLAudioElementHost();
+ const audio = document.createElement('audio');
+ audio.muted = false;
+ host.attach(audio);
+
+ const component = new AttachTracking();
+ addComponent(host, component);
+ addComponent(host, new MutedOverride());
+
+ host.destroy();
+
+ expect(component.destroy).toHaveBeenCalledTimes(1);
+
+ // The destroyed override no longer participates in property resolution.
+ host.attach(audio);
+ expect(host.muted).toBe(false);
+ });
+
+ it('invokes the override method when it owns the property', async () => {
+ const host = new HTMLAudioElementHost();
+ const audio = document.createElement('audio');
+ host.attach(audio);
+
+ const component = new CastLikeOverride();
+ addComponent(host, component);
+
+ await host.play();
+
+ expect(component.api.playCount).toBe(1);
+ });
+ });
+
+ describe('play', () => {
+ it('rejects when nothing is attached', async () => {
+ const host = new HTMLAudioElementHost();
+
+ await expect(host.play()).rejects.toBeInstanceOf(DOMException);
+ });
+
+ it('rejects when the target lacks a play implementation', async () => {
+ const host = new HTMLAudioElementHost();
+ host.attach({} as HTMLAudioElement);
+
+ await expect(host.play()).rejects.toBeInstanceOf(DOMException);
+ });
+ });
+
+ describe('component config binding', () => {
+ it('exposes the component instance under its configKey', () => {
+ const host = new HTMLAudioElementHost();
+ const component = new ConfigurableComponent();
+ addComponent(host, component);
+
+ expect(host.config.fake).toBe(component);
+ });
+
+ it('reads live values from the component', () => {
+ const host = new HTMLAudioElementHost();
+ const component = new ConfigurableComponent();
+ addComponent(host, component);
+
+ component.value = 5;
+ expect((host.config.fake as ConfigurableComponent).value).toBe(5);
+ });
+
+ it('assigns onto the component when the namespace is written', () => {
+ const host = new HTMLAudioElementHost();
+ const component = new ConfigurableComponent();
+ addComponent(host, component);
+
+ host.config.fake = { value: 7, label: 'hi' };
+
+ expect(component.value).toBe(7);
+ expect(component.label).toBe('hi');
+ });
+
+ it('routes component keys through the config setter', () => {
+ const host = new HTMLAudioElementHost();
+ const component = new ConfigurableComponent();
+ addComponent(host, component);
+
+ host.config = { fake: { value: 3 }, hlsJs: { debug: true } };
+
+ expect(component.value).toBe(3);
+ // Component instance is not stored as a plain object on the host.
+ expect(host.config.fake).toBe(component);
+ // Non-component keys are stored on the host bag.
+ expect(host.config.hlsJs).toEqual({ debug: true });
+ });
+
+ it('reflects a component added after the first config access', () => {
+ const host = new HTMLAudioElementHost();
+ // Access config before the component exists to build the proxy.
+ expect(host.config.fake).toBeUndefined();
+
+ const component = new ConfigurableComponent();
+ addComponent(host, component);
+
+ expect(host.config.fake).toBe(component);
+ });
+
+ it('includes the configKey in has/ownKeys', () => {
+ const host = new HTMLAudioElementHost();
+ addComponent(host, new ConfigurableComponent());
+
+ expect('fake' in host.config).toBe(true);
+ expect(Object.keys(host.config)).toContain('fake');
+ });
+
+ it('merges host-level keys on assignment', () => {
+ const host = new HTMLAudioElementHost();
+
+ host.config = { a: 1 };
+ host.config = { b: 2 };
+
+ expect(host.config.a).toBe(1);
+ expect(host.config.b).toBe(2);
+ });
+
+ it('removes the config binding when the component is removed', () => {
+ const host = new HTMLAudioElementHost();
+ const remove = addComponent(host, new ConfigurableComponent());
+
+ remove();
+
+ expect(host.config.fake).toBeUndefined();
+ expect('fake' in host.config).toBe(false);
+ });
+
+ it('adopts config set before the component was registered', () => {
+ const host = new HTMLAudioElementHost();
+ host.config = { fake: { value: 4, label: 'early' } };
+
+ const component = new ConfigurableComponent();
+ addComponent(host, component);
+
+ expect(component.value).toBe(4);
+ expect(component.label).toBe('early');
+ expect(host.config.fake).toBe(component);
+ });
+ });
+});
diff --git a/packages/core/src/dom/media/tests/predicate.test.ts b/packages/core/src/dom/media/tests/predicate.test.ts
new file mode 100644
index 00000000..ea21a195
--- /dev/null
+++ b/packages/core/src/dom/media/tests/predicate.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+import { EMPTY_REMOTE, EMPTY_TEXT_TRACKS, EMPTY_TIME_RANGES } from '../constants';
+import { isMediaBufferCapable, isMediaRemotePlaybackCapable, isMediaTextTrackCapable } from '../predicate';
+
+describe('isMediaBufferCapable', () => {
+ it('rejects empty time range stubs', () => {
+ expect(isMediaBufferCapable({ buffered: EMPTY_TIME_RANGES, seekable: EMPTY_TIME_RANGES })).toBe(false);
+ });
+
+ it('accepts defined non-stub time ranges', () => {
+ const range = { length: 1, start: () => 0, end: () => 10 };
+ expect(isMediaBufferCapable({ buffered: range, seekable: range })).toBe(true);
+ });
+});
+
+describe('isMediaTextTrackCapable', () => {
+ it('rejects the empty text tracks stub', () => {
+ expect(isMediaTextTrackCapable({ textTracks: EMPTY_TEXT_TRACKS })).toBe(false);
+ });
+
+ it('accepts defined non-stub text tracks', () => {
+ expect(isMediaTextTrackCapable({ textTracks: Object.assign(new EventTarget(), { length: 0 }) })).toBe(true);
+ });
+});
+
+describe('isMediaRemotePlaybackCapable', () => {
+ it('rejects the empty remote playback stub', () => {
+ expect(isMediaRemotePlaybackCapable({ remote: EMPTY_REMOTE })).toBe(false);
+ });
+
+ it('accepts defined non-stub remote playback', () => {
+ expect(isMediaRemotePlaybackCapable({ remote: new EventTarget() })).toBe(true);
+ });
+});
diff --git a/packages/core/src/dom/media/utils.ts b/packages/core/src/dom/media/utils.ts
new file mode 100644
index 00000000..874889e8
--- /dev/null
+++ b/packages/core/src/dom/media/utils.ts
@@ -0,0 +1,69 @@
+import { isFunction } from '@videojs/utils/predicate';
+import type {
+ Component,
+ ComponentConstructor,
+ Components,
+ HTMLMediaElementHost,
+ HTMLMediaTargetLike as TargetLike,
+} from './media-host';
+
+type Host = HTMLMediaElementHost;
+
+const registry = new WeakMap();
+
+export function getComponents(host: Host) {
+ let map = registry.get(host);
+ if (!map) registry.set(host, (map = new Map() as Components));
+ return map;
+}
+
+export function addComponent(host: Host, instance: T) {
+ const components = getComponents(host);
+ const ctor = instance.constructor as ComponentConstructor;
+ components.set(ctor, instance);
+
+ // Expose a live binding on `host.config`: reads return the component, writes assign onto it.
+ const { configKey } = ctor;
+ if (configKey) {
+ // Adopt config set before the component was registered.
+ const initial = host.config[configKey];
+ Object.defineProperty(host.config, configKey, {
+ enumerable: true,
+ configurable: true,
+ get: () => instance,
+ set: (value) => Object.assign(instance, value),
+ });
+ if (initial) Object.assign(instance, initial);
+ }
+
+ instance.setMedia?.(host);
+ // @ts-expect-error `target` is protected, but these helpers are the host's own machinery.
+ if (host.target) instance.attach?.(host.target);
+ return () => {
+ if (components.get(ctor) === instance) {
+ components.delete(ctor);
+ if (configKey) delete host.config[configKey];
+ }
+ };
+}
+
+export function getProp(host: Host, prop: K): T[K] | undefined {
+ const own = getOwner(host, prop);
+ const result = own?.[prop];
+ return isFunction(result) ? (result.bind(own) as T[K]) : result;
+}
+
+export function setProp(host: Host, prop: K, value: T[K]): void {
+ const own = getOwner(host, prop);
+ if (own) (own as Record)[prop] = value;
+}
+
+/** Find the object that owns a media property: the first component `override` exposing it, otherwise the attached target. */
+export function getOwner(host: Host, prop: keyof T): Partial | null {
+ for (const component of getComponents(host).values()) {
+ const override = component.targetOverride as Partial | null | undefined;
+ if (override?.[prop] !== undefined) return override;
+ }
+ // @ts-expect-error `target` is protected, but these helpers are the host's own machinery.
+ return host.target;
+}
diff --git a/packages/core/src/dom/media/video-host.ts b/packages/core/src/dom/media/video-host.ts
index cbcf1670..f522e9a9 100644
--- a/packages/core/src/dom/media/video-host.ts
+++ b/packages/core/src/dom/media/video-host.ts
@@ -1,15 +1,41 @@
import type { WebKitDocument, WebKitPresentationMode, WebKitVideoElement } from '@videojs/utils/dom';
import { isFunction } from '@videojs/utils/predicate';
-import type { Video, VideoEvents } from '../../core/media/types';
-import { HTMLMediaElementHost } from './media-host';
+import type { Video, VideoEvents, VideoTargetLike } from '../../core/media/types';
+import { getProp, HTMLMediaElementHost, type HTMLMediaTargetLike, setProp } from './media-host';
-export class HTMLVideoElementHost extends HTMLMediaElementHost implements Video {
+export interface HTMLVideoTargetLike extends VideoTargetLike, HTMLMediaTargetLike {}
+
+export class HTMLVideoElementHost extends HTMLMediaElementHost implements Video {
get poster() {
- return this.target?.poster ?? '';
+ return getProp(this, 'poster') ?? '';
}
set poster(value: string) {
- if (this.target) this.target.poster = value;
+ setProp(this, 'poster', value);
+ }
+
+ get playsInline() {
+ return getProp(this, 'playsInline') ?? false;
+ }
+
+ set playsInline(value: boolean) {
+ setProp(this, 'playsInline', value);
+ }
+
+ get videoWidth() {
+ return getProp(this, 'videoWidth') ?? 0;
+ }
+
+ get videoHeight() {
+ return getProp(this, 'videoHeight') ?? 0;
+ }
+
+ get disablePictureInPicture() {
+ return getProp(this, 'disablePictureInPicture') ?? false;
+ }
+
+ set disablePictureInPicture(value: boolean) {
+ setProp(this, 'disablePictureInPicture', value);
}
get webkitPresentationMode() {
@@ -23,17 +49,19 @@ export class HTMLVideoElementHost extends HTMLMediaElementHost ({
entry: {
index: './src/core/index.ts',
dom: './src/dom/index.ts',
+ 'dom/media/media-host/index': './src/dom/media/media-host.ts',
+ 'dom/media/custom-media-element/index': './src/dom/media/custom-media-element/index.ts',
+ // Media
'dom/media/dash/index': './src/dom/media/dash/index.ts',
'dom/media/hls/index': './src/dom/media/hls/index.ts',
- 'dom/media/custom-media-element/index': './src/dom/media/custom-media-element/index.ts',
- 'dom/media/mux/index': './src/dom/media/mux/index.ts',
'dom/media/native-hls/index': './src/dom/media/native-hls/index.ts',
'dom/media/simple-hls-audio-only/index': './src/dom/media/simple-hls-audio-only/index.ts',
'dom/media/simple-hls/index': './src/dom/media/simple-hls/index.ts',
+ // Components
+ 'dom/media/mux/index': './src/dom/media/mux/index.ts',
+ 'dom/media/google-cast/index': './src/dom/media/google-cast/index.ts',
},
define: {
__DEV__: mode === 'dev' ? 'true' : 'false',
diff --git a/packages/html/src/media/dash-video/index.ts b/packages/html/src/media/dash-video/index.ts
index 269e4ac0..8f1fc1b1 100644
--- a/packages/html/src/media/dash-video/index.ts
+++ b/packages/html/src/media/dash-video/index.ts
@@ -1,5 +1,12 @@
import { CustomMediaElement } from '@videojs/core/dom/media/custom-media-element';
import { DashMedia } from '@videojs/core/dom/media/dash';
+import { GoogleCast } from '@videojs/core/dom/media/google-cast';
+import { addComponent } from '@videojs/core/dom/media/media-host';
import { MediaAttachMixin } from '../../store/media-attach-mixin';
-export class DashVideo extends MediaAttachMixin(CustomMediaElement('video', DashMedia)) {}
+export class DashVideo extends MediaAttachMixin(CustomMediaElement('video', DashMedia)) {
+ constructor() {
+ super();
+ addComponent(this.host, new GoogleCast());
+ }
+}
diff --git a/packages/html/src/media/hls-video/index.ts b/packages/html/src/media/hls-video/index.ts
index d38e5c90..886699ba 100644
--- a/packages/html/src/media/hls-video/index.ts
+++ b/packages/html/src/media/hls-video/index.ts
@@ -1,10 +1,12 @@
import { CustomMediaElement } from '@videojs/core/dom/media/custom-media-element';
+import { GoogleCast } from '@videojs/core/dom/media/google-cast';
import { HlsMedia } from '@videojs/core/dom/media/hls';
+import { addComponent } from '@videojs/core/dom/media/media-host';
import { MediaAttachMixin } from '../../store/media-attach-mixin';
export class HlsVideo extends MediaAttachMixin(CustomMediaElement('video', HlsMedia)) {
- static get observedAttributes() {
- // biome-ignore lint/complexity/noThisInStatic: intentional use of super
- return [...super.observedAttributes, 'type', 'prefer-playback', 'debug'];
+ constructor() {
+ super();
+ addComponent(this.host, new GoogleCast());
}
}
diff --git a/packages/html/src/media/mux-audio/index.ts b/packages/html/src/media/mux-audio/index.ts
index 830eb963..814034dc 100644
--- a/packages/html/src/media/mux-audio/index.ts
+++ b/packages/html/src/media/mux-audio/index.ts
@@ -1,18 +1,14 @@
import { CustomMediaElement } from '@videojs/core/dom/media/custom-media-element';
-import { MuxAudioMedia } from '@videojs/core/dom/media/mux';
+import { GoogleCast } from '@videojs/core/dom/media/google-cast';
+import { HlsMedia } from '@videojs/core/dom/media/hls';
+import { addComponent } from '@videojs/core/dom/media/media-host';
+import { MuxData } from '@videojs/core/dom/media/mux';
import { MediaAttachMixin } from '../../store/media-attach-mixin';
-export class MuxAudio extends MediaAttachMixin(CustomMediaElement('audio', MuxAudioMedia)) {
- static get observedAttributes() {
- return [
- // biome-ignore lint/complexity/noThisInStatic: intentional use of super
- ...super.observedAttributes,
- 'type',
- 'prefer-playback',
- 'debug',
- 'cast-src',
- 'cast-receiver',
- 'cast-content-type',
- ];
+export class MuxAudio extends MediaAttachMixin(CustomMediaElement('audio', HlsMedia)) {
+ constructor() {
+ super();
+ addComponent(this.host, new MuxData({ playerSoftwareName: 'mux-audio' }));
+ addComponent(this.host, new GoogleCast());
}
}
diff --git a/packages/html/src/media/mux-video/index.ts b/packages/html/src/media/mux-video/index.ts
index 4bc18462..598d427d 100644
--- a/packages/html/src/media/mux-video/index.ts
+++ b/packages/html/src/media/mux-video/index.ts
@@ -1,18 +1,14 @@
import { CustomMediaElement } from '@videojs/core/dom/media/custom-media-element';
-import { MuxVideoMedia } from '@videojs/core/dom/media/mux';
+import { GoogleCast } from '@videojs/core/dom/media/google-cast';
+import { HlsMedia } from '@videojs/core/dom/media/hls';
+import { addComponent } from '@videojs/core/dom/media/media-host';
+import { MuxData } from '@videojs/core/dom/media/mux';
import { MediaAttachMixin } from '../../store/media-attach-mixin';
-export class MuxVideo extends MediaAttachMixin(CustomMediaElement('video', MuxVideoMedia)) {
- static get observedAttributes() {
- return [
- // biome-ignore lint/complexity/noThisInStatic: intentional use of super
- ...super.observedAttributes,
- 'type',
- 'prefer-playback',
- 'debug',
- 'cast-src',
- 'cast-receiver',
- 'cast-content-type',
- ];
+export class MuxVideo extends MediaAttachMixin(CustomMediaElement('video', HlsMedia)) {
+ constructor() {
+ super();
+ addComponent(this.host, new MuxData({ playerSoftwareName: 'mux-video' }));
+ addComponent(this.host, new GoogleCast());
}
}
diff --git a/packages/html/src/media/native-hls-video/index.ts b/packages/html/src/media/native-hls-video/index.ts
index 4548daa6..a7d9b74d 100644
--- a/packages/html/src/media/native-hls-video/index.ts
+++ b/packages/html/src/media/native-hls-video/index.ts
@@ -1,5 +1,12 @@
import { CustomMediaElement } from '@videojs/core/dom/media/custom-media-element';
+import { GoogleCast } from '@videojs/core/dom/media/google-cast';
+import { addComponent } from '@videojs/core/dom/media/media-host';
import { NativeHlsMedia } from '@videojs/core/dom/media/native-hls';
import { MediaAttachMixin } from '../../store/media-attach-mixin';
-export class NativeHlsVideo extends MediaAttachMixin(CustomMediaElement('video', NativeHlsMedia)) {}
+export class NativeHlsVideo extends MediaAttachMixin(CustomMediaElement('video', NativeHlsMedia)) {
+ constructor() {
+ super();
+ addComponent(this.host, new GoogleCast());
+ }
+}
diff --git a/packages/html/src/media/tests/mux-video.test.ts b/packages/html/src/media/tests/mux-video.test.ts
new file mode 100644
index 00000000..b0d9bfa1
--- /dev/null
+++ b/packages/html/src/media/tests/mux-video.test.ts
@@ -0,0 +1,35 @@
+import { MuxData } from '@videojs/core/dom/media/mux';
+import { afterEach, describe, expect, it } from 'vitest';
+import { MuxVideo } from '../mux-video';
+
+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;
+}
+
+afterEach(() => {
+ document.body.innerHTML = '';
+});
+
+describe('MuxVideo', () => {
+ it('exposes the mux data component config on element config', () => {
+ const el = createMuxVideo();
+
+ expect(el.config.muxData).toBeInstanceOf(MuxData);
+ expect((el.config.muxData as MuxData).playerSoftwareName).toBe('mux-video');
+ });
+
+ it('routes component config writes to the component', () => {
+ const el = createMuxVideo();
+
+ el.config = { muxData: { envKey: 'test-key' } };
+
+ expect(el.config.muxData?.envKey).toBe('test-key');
+ expect(el.hasAttribute('config')).toBe(false);
+ });
+});
diff --git a/packages/react/src/media/dash-video/index.tsx b/packages/react/src/media/dash-video/index.tsx
index 818f5848..e73bcbc1 100644
--- a/packages/react/src/media/dash-video/index.tsx
+++ b/packages/react/src/media/dash-video/index.tsx
@@ -2,6 +2,8 @@
import type { DashMediaProps } from '@videojs/core/dom/media/dash';
import { DashMedia, dashMediaDefaultProps } from '@videojs/core/dom/media/dash';
+import { GoogleCast } from '@videojs/core/dom/media/google-cast';
+import { addComponent } from '@videojs/core/dom/media/media-host';
import type { ReactNode, VideoHTMLAttributes } from 'react';
import { forwardRef } from 'react';
import { useAttachMedia } from '../../utils/use-attach-media';
@@ -16,7 +18,9 @@ export interface DashVideoProps
}
export const DashVideo = forwardRef(function DashVideo({ children, ...props }, ref) {
- const media = useMediaInstance(DashMedia);
+ const media = useMediaInstance(DashMedia, (media) => {
+ addComponent(media, new GoogleCast());
+ });
const attachRef = useAttachMedia(media);
const composedRef = useComposedRefs(attachRef, ref);
const htmlProps = useSyncProps(media, props, dashMediaDefaultProps);
diff --git a/packages/react/src/media/hls-video/index.tsx b/packages/react/src/media/hls-video/index.tsx
index 42357225..af24845c 100644
--- a/packages/react/src/media/hls-video/index.tsx
+++ b/packages/react/src/media/hls-video/index.tsx
@@ -1,7 +1,9 @@
'use client';
+import { GoogleCast } from '@videojs/core/dom/media/google-cast';
import type { HlsMediaProps } from '@videojs/core/dom/media/hls';
import { HlsMedia, hlsMediaDefaultProps } from '@videojs/core/dom/media/hls';
+import { addComponent } from '@videojs/core/dom/media/media-host';
import type { ReactNode, VideoHTMLAttributes } from 'react';
import { forwardRef } from 'react';
import { useAttachMedia } from '../../utils/use-attach-media';
@@ -16,7 +18,9 @@ export interface HlsVideoProps
}
export const HlsVideo = forwardRef(function HlsVideo({ children, ...props }, ref) {
- const media = useMediaInstance(HlsMedia);
+ const media = useMediaInstance(HlsMedia, (media) => {
+ addComponent(media, new GoogleCast());
+ });
const attachRef = useAttachMedia(media);
const composedRef = useComposedRefs(attachRef, ref);
const htmlProps = useSyncProps(media, props, hlsMediaDefaultProps);
diff --git a/packages/react/src/media/mux-audio/index.tsx b/packages/react/src/media/mux-audio/index.tsx
index 0dbd86f3..cc811085 100644
--- a/packages/react/src/media/mux-audio/index.tsx
+++ b/packages/react/src/media/mux-audio/index.tsx
@@ -1,7 +1,10 @@
'use client';
-import type { MuxMediaProps } from '@videojs/core/dom/media/mux';
-import { MuxAudioMedia, muxMediaDefaultProps } from '@videojs/core/dom/media/mux';
+import { GoogleCast } from '@videojs/core/dom/media/google-cast';
+import type { HlsMediaProps } from '@videojs/core/dom/media/hls';
+import { HlsMedia, hlsMediaDefaultProps } from '@videojs/core/dom/media/hls';
+import { addComponent } from '@videojs/core/dom/media/media-host';
+import { MuxData } from '@videojs/core/dom/media/mux';
import type { AudioHTMLAttributes, ReactNode } from 'react';
import { forwardRef } from 'react';
import { useAttachMedia } from '../../utils/use-attach-media';
@@ -10,16 +13,19 @@ import { useMediaInstance } from '../../utils/use-media-instance';
import { useSyncProps } from '../../utils/use-sync-props';
export interface MuxAudioProps
- extends Omit, keyof MuxMediaProps>,
- Partial {
+ extends Omit, keyof HlsMediaProps>,
+ Partial {
children?: ReactNode;
}
export const MuxAudio = forwardRef(function MuxAudio({ children, ...props }, ref) {
- const media = useMediaInstance(MuxAudioMedia);
+ const media = useMediaInstance(HlsMedia, (media) => {
+ addComponent(media, new MuxData({ playerSoftwareName: 'mux-audio' }));
+ addComponent(media, new GoogleCast());
+ });
const attachRef = useAttachMedia(media);
const composedRef = useComposedRefs(attachRef, ref);
- const htmlProps = useSyncProps(media, props, muxMediaDefaultProps);
+ const htmlProps = useSyncProps(media, props, hlsMediaDefaultProps);
return (