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() {
<${tag}> - +
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`Video poster` : ''} 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 (