diff --git a/apps/sandbox/app/shared/sources.ts b/apps/sandbox/app/shared/sources.ts index 9781eed3..ad85b66a 100644 --- a/apps/sandbox/app/shared/sources.ts +++ b/apps/sandbox/app/shared/sources.ts @@ -4,54 +4,46 @@ export const SOURCES = { 'hls-1': { label: 'HLS - Big Buck Bunny', url: 'https://stream.mux.com/VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA.m3u8', - playbackId: 'VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA', type: 'hls', subType: 'ts', }, 'hls-2': { label: 'HLS - Elephants Dream', url: 'https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008.m3u8', - playbackId: 'Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008', type: 'hls', subType: 'ts', }, 'hls-3': { label: 'HLS - Dancing Dude', url: 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4.m3u8', - playbackId: 'lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4', type: 'hls', subType: 'mp4', }, 'hls-4': { label: 'HLS - View From A Blue Moon Trailer', url: 'https://stream.mux.com/lyrKpPcGfqyzeI00jZAfW6MvP6GNPrkML.m3u8', - playbackId: 'lyrKpPcGfqyzeI00jZAfW6MvP6GNPrkML', type: 'hls', subType: 'mp4', }, 'hls-5': { label: 'HLS - Mad Max Fury Road Trailer', url: 'https://stream.mux.com/JX01bG8eB4uaoV3OpDuK602rBfvdSgrMObjwuUOBn4JrQ.m3u8', - playbackId: 'JX01bG8eB4uaoV3OpDuK602rBfvdSgrMObjwuUOBn4JrQ', type: 'hls', subType: 'mp4', }, 'mp4-1': { label: 'MP4 - Dancing Dude', url: 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4', - playbackId: 'lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4', type: 'mp4', }, 'dash-1': { label: 'DASH - Big Buck Bunny', url: 'https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd', - playbackId: null, type: 'dash', }, 'dash-2': { label: 'DASH - Envivio Test Stream', url: 'https://dash.akamaized.net/envivio/EnvivioDash3/manifest.mpd', - playbackId: null, type: 'dash', }, } as const; @@ -62,7 +54,6 @@ export const SOURCE_IDS = Object.keys(SOURCES) as SourceId[]; export const NON_DASH_SOURCE_IDS = SOURCE_IDS.filter((id) => SOURCES[id].type !== 'dash'); export const MP4_SOURCE_IDS = SOURCE_IDS.filter((id) => SOURCES[id].type === 'mp4'); export const DASH_SOURCE_IDS = SOURCE_IDS.filter((id) => SOURCES[id].type === 'dash'); -export const MUX_SOURCE_IDS = SOURCE_IDS.filter((id) => !!SOURCES[id].playbackId); export const DEFAULT_SOURCE: SourceId = 'hls-1'; export const DEFAULT_AUDIO_SOURCE: SourceId = 'mp4-1'; export const DEFAULT_DASH_SOURCE: SourceId = 'dash-1'; diff --git a/apps/sandbox/app/shell/app.tsx b/apps/sandbox/app/shell/app.tsx index 6de7c756..e25fb184 100644 --- a/apps/sandbox/app/shell/app.tsx +++ b/apps/sandbox/app/shell/app.tsx @@ -6,7 +6,6 @@ import { DEFAULT_DASH_SOURCE, DEFAULT_SOURCE, MP4_SOURCE_IDS, - MUX_SOURCE_IDS, NON_DASH_SOURCE_IDS, SOURCES, } from '@app/shared/sources'; @@ -78,13 +77,6 @@ export function App() { } }, [preset, source, setSource]); - // Constrain source to Mux playbackId sources when switching to mux-video / mux-audio - useEffect(() => { - if ((preset === 'mux-video' || preset === 'mux-audio') && !SOURCES[source].playbackId) { - setSource(DEFAULT_SOURCE); - } - }, [preset, source, setSource]); - // CDN and background video do not have a Tailwind skin variant. useEffect(() => { if ((platform === 'cdn' || preset === 'background-video') && styling === 'tailwind') { @@ -93,13 +85,7 @@ export function App() { }, [platform, preset, styling]); const availableSources = - preset === 'audio' - ? MP4_SOURCE_IDS - : preset === 'dash-video' - ? DASH_SOURCE_IDS - : preset === 'mux-video' || preset === 'mux-audio' - ? MUX_SOURCE_IDS - : NON_DASH_SOURCE_IDS; + preset === 'audio' ? MP4_SOURCE_IDS : preset === 'dash-video' ? DASH_SOURCE_IDS : NON_DASH_SOURCE_IDS; const handleSourceChange = useCallback((value: string) => setSource(value as SourceId), [setSource]); diff --git a/apps/sandbox/templates/cdn/main.ts b/apps/sandbox/templates/cdn/main.ts index acb37faf..464543ee 100644 --- a/apps/sandbox/templates/cdn/main.ts +++ b/apps/sandbox/templates/cdn/main.ts @@ -128,12 +128,7 @@ async function render() { const storyboard = isVideoPreset(preset) ? getStoryboardSrc(state.source) : undefined; const poster = isVideoPreset(preset) ? getPosterSrc(state.source) : undefined; - const sourceAttr = - preset === 'background-video' - ? `src="${BACKGROUND_VIDEO_SRC}"` - : (preset === 'mux-video' || preset === 'mux-audio') && source.type !== 'mp4' - ? `playback-id="${source.playbackId}"` - : `src="${source.url}"`; + const sourceAttr = preset === 'background-video' ? `src="${BACKGROUND_VIDEO_SRC}"` : `src="${source.url}"`; // Background video needs viewport dimensions instead of flex centering. if (preset === 'background-video') { diff --git a/apps/sandbox/templates/html-mux-audio/main.ts b/apps/sandbox/templates/html-mux-audio/main.ts index d95d5e6b..3a2e45bf 100644 --- a/apps/sandbox/templates/html-mux-audio/main.ts +++ b/apps/sandbox/templates/html-mux-audio/main.ts @@ -15,16 +15,11 @@ async function render() { const tag = await loadLatest(() => loadAudioSkinTag(state.skin, state.styling)); if (!tag) return; - const sourceAttr = - SOURCES[state.source].type === 'mp4' - ? `src="${SOURCES[state.source].url}"` - : `playback-id="${SOURCES[state.source].playbackId}"`; - document.getElementById('root')!.innerHTML = html`
<${tag}> - +
diff --git a/apps/sandbox/templates/html-mux-video/main.ts b/apps/sandbox/templates/html-mux-video/main.ts index edcd096f..d1ead60a 100644 --- a/apps/sandbox/templates/html-mux-video/main.ts +++ b/apps/sandbox/templates/html-mux-video/main.ts @@ -19,15 +19,10 @@ async function render() { const storyboard = getStoryboardSrc(state.source); const poster = getPosterSrc(state.source); - const sourceAttr = - SOURCES[state.source].type === 'mp4' - ? `src="${SOURCES[state.source].url}"` - : `playback-id="${SOURCES[state.source].playbackId}"`; - document.getElementById('root')!.innerHTML = html` <${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 097e366d..40716fe5 100644 --- a/apps/sandbox/templates/react-mux-audio/main.tsx +++ b/apps/sandbox/templates/react-mux-audio/main.tsx @@ -18,13 +18,10 @@ function App() { const source = useSource(); const styling = useMemo(readStyling, []); - const sourceAttr = - SOURCES[source].type === 'mp4' ? { src: SOURCES[source].url } : { playbackId: SOURCES[source].playbackId }; - return ( - + ); diff --git a/apps/sandbox/templates/react-mux-video/main.tsx b/apps/sandbox/templates/react-mux-video/main.tsx index f093ddea..945123f8 100644 --- a/apps/sandbox/templates/react-mux-video/main.tsx +++ b/apps/sandbox/templates/react-mux-video/main.tsx @@ -23,13 +23,10 @@ function App() { const poster = usePoster(); const storyboard = useStoryboard(); - const sourceAttr = - SOURCES[source].type === 'mp4' ? { src: SOURCES[source].url } : { playbackId: SOURCES[source].playbackId }; - return ( - + diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index 648e1551..7ba45be9 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -1,5 +1,3 @@ -export * from './media/delegate'; -export * from './media/proxy'; export * from './media/state'; export * from './media/types'; export * from './ui/alert-dialog/alert-dialog-core'; diff --git a/packages/core/src/core/media/delegate.ts b/packages/core/src/core/media/delegate.ts deleted file mode 100644 index 8b0610fb..00000000 --- a/packages/core/src/core/media/delegate.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Constructor } from '@videojs/utils/types'; -import { bridgeEvents } from '../utils/bridge-events'; -import { defineClassPropHooks } from '../utils/define-class-prop-hooks'; - -export interface Delegate { - attach?(target: EventTarget): void; - detach?(): void; -} - -export interface BaseType extends EventTarget { - attach?(target: EventTarget): void; - detach?(): void; - get?(prop: string): any; - set?(prop: string, val: any): void; - call?(prop: string, ...args: any[]): any; -} - -/** - * Mixin that intercepts `get`, `set`, and `call` to delegate property access - * and method calls to an instance of `DelegateClass` before falling through - * to the base class implementation. - * - * Works with both `CustomMediaMixin` and `ProxyMixin`. - */ -export function DelegateMixin, D extends Constructor>( - BaseClass: Base, - DelegateClass: D -) { - class DelegateImpl extends BaseClass { - #delegate = new DelegateClass(); - - constructor(...args: any[]) { - super(...args); - - if (this.#delegate instanceof EventTarget) { - bridgeEvents(this.#delegate, this); - } - } - - get(prop: string): any { - if (prop in this.#delegate) { - return (this.#delegate as any)[prop]; - } - return super.get?.(prop); - } - - set(prop: string, val: any): void { - if (prop in this.#delegate) { - (this.#delegate as any)[prop] = val; - return; - } - super.set?.(prop, val); - } - - call(prop: string, ...args: any[]): any { - if (prop in this.#delegate) { - return (this.#delegate as any)[prop](...args); - } - return super.call?.(prop, ...args); - } - - attach(target: EventTarget): void { - this.#delegate.attach?.(target); - super.attach?.(target); - } - - detach(): void { - this.#delegate.detach?.(); - super.detach?.(); - } - } - - for (let proto = DelegateClass.prototype; proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) { - defineClassPropHooks(DelegateImpl, proto); - } - - return DelegateImpl as unknown as Constructor & InstanceType> & Omit; -} diff --git a/packages/core/src/core/media/proxy.ts b/packages/core/src/core/media/proxy.ts deleted file mode 100644 index 042f2279..00000000 --- a/packages/core/src/core/media/proxy.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { AnyConstructor, Constructor } from '@videojs/utils/types'; -import { defineClassPropHooks } from '../utils/define-class-prop-hooks'; - -export interface MediaProxy { - readonly target: EventTarget | null; - get(prop: keyof EventTarget): any; - set(prop: keyof EventTarget, val: any): void; - call(prop: keyof EventTarget, ...args: any[]): any; - attach(target: EventTarget): void; - detach(): void; -} - -/** - * This mixin creates an API from the passed classes and proxies the methods and properties to the attached target. - * - * Many methods and properties will need no translation and are proxied directly to the attached target. - * For example, the `play` and `pause` methods are proxied directly to the attached target. - * - * Child classes can override the proxied methods and properties to provide custom behavior. - * For example, the `src` property for HLS media is proxied to the HLS engine, not the target itself. - * - * The `get`, `set`, and `call` methods can be overridden to provide catch-all custom behavior. - */ -export const ProxyMixin = (BaseClass: AnyConstructor) => { - class MediaProxyImpl extends EventTarget { - #target: EventTarget | null = null; - #types = new Set(); - - get target() { - return this.#target; - } - - get(prop: keyof EventTarget): any { - return this.target?.[prop]; - } - - set(prop: keyof EventTarget, val: any): void { - if (this.target) { - this.target[prop] = val; - } - } - - call(prop: keyof EventTarget, ...args: any[]): any { - const nativeFn = this.target?.[prop] as ((...args: any[]) => any) | undefined; - return nativeFn?.apply(this.target, args); - } - - attach(target: EventTarget): void { - if (!target || this.#target === target) return; - this.#target = target; - for (const type of this.#types) { - target.addEventListener(type, this.#forwardEvent); - } - } - - detach(): void { - if (!this.#target) return; - for (const type of this.#types) { - this.#target.removeEventListener(type, this.#forwardEvent); - } - this.#target = null; - } - - addEventListener( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions - ): void { - if (!this.#types.has(type)) { - this.#types.add(type); - this.#target?.addEventListener(type, this.#forwardEvent); - } - super.addEventListener(type, listener, options); - } - - #forwardEvent = (event: Event) => { - this.dispatchEvent(new (event.constructor as typeof Event)(event.type, event)); - }; - } - - for ( - let proto = BaseClass.prototype; - proto && !Object.prototype.isPrototypeOf.call(proto, MediaProxyImpl.prototype); - proto = Object.getPrototypeOf(proto) - ) { - defineClassPropHooks(MediaProxyImpl, proto); - } - - return MediaProxyImpl as unknown as Constructor; -}; diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index 443c3eab..f01be4af 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -37,8 +37,7 @@ export interface MediaPlaybackState { togglePaused(): boolean; } -/** Indicates whether a feature can be programmatically controlled on this platform. */ -export type MediaFeatureAvailability = 'available' | 'unavailable' | 'unsupported'; +import type { MediaFeatureAvailability } from './types'; export interface MediaVolumeState { /** @@ -227,7 +226,7 @@ export type TextTrackMode = 'showing' | 'disabled' | 'hidden'; * * @see https://developer.mozilla.org/en-US/docs/Web/API/TextTrack */ -export interface MediaTextTrack { +export interface MediaTextTrack { kind: Kind; label: string; language: string; @@ -242,7 +241,7 @@ export interface MediaTextTrackState { /** The `` element's `src` for resolving relative cue text URLs. */ thumbnailTrackSrc: string | null; /** All text tracks available on the media element. */ - textTrackList: MediaTextTrack[]; + textTrackList: MediaTextTrack[]; /** Whether captions/subtitles are currently enabled. */ subtitlesShowing: boolean; /** Toggle captions/subtitles visibility. Returns the new enabled value. */ diff --git a/packages/core/src/core/media/tests/delegate.test.ts b/packages/core/src/core/media/tests/delegate.test.ts index a588f619..6d7e76fe 100644 --- a/packages/core/src/core/media/tests/delegate.test.ts +++ b/packages/core/src/core/media/tests/delegate.test.ts @@ -1,92 +1,77 @@ +import type { Constructor } from '@videojs/utils/types'; import { describe, expect, it, vi } from 'vitest'; +import { bridgeEvents } from '../../utils/bridge-events'; -import { DelegateMixin } from '../delegate'; -import { ProxyMixin } from '../proxy'; - -class FakeBase extends EventTarget { - get(_prop: string): any {} - set(_prop: string, _val: any): void {} - call(_prop: string, ..._args: any[]): any {} - attach(_target: EventTarget): void {} - detach(): void {} +interface MediaBase extends EventTarget { + readonly target: EventTarget | null; + attach?(target: EventTarget): void; + detach?(): void; } -class EventfulDelegate extends EventTarget { - attach(_target: EventTarget): void {} - detach(): void {} +function TestMediaMixin>(BaseClass: Base) { + return class TestMedia extends BaseClass { + #subDelegate = new EventTarget(); + #src = ''; - fire(): void { - this.dispatchEvent(new Event('custom')); - } + get src() { + return this.#src; + } + + set src(value: string) { + this.#src = value; + } + + fire() { + this.#subDelegate.dispatchEvent(new Event('custom')); + } + + attach(target: EventTarget) { + bridgeEvents(this.#subDelegate, this); + super.attach?.(target); + } + + detach() { + super.detach?.(); + } + }; } -const Mixed = DelegateMixin(FakeBase, EventfulDelegate); - -describe('DelegateMixin', () => { - describe('event forwarding', () => { - it('forwards events dispatched by the delegate to the host', () => { +describe('Media Mixins', () => { + describe('event bridging from sub-delegates', () => { + it('forwards events dispatched by a sub-delegate to the host', () => { + class Base extends EventTarget { + get target() { + return null; + } + } + const Mixed = TestMediaMixin(Base as unknown as Constructor); const host = new Mixed(); + const handler = vi.fn(); host.addEventListener('custom', handler); + host.attach(new EventTarget()); host.fire(); expect(handler).toHaveBeenCalledOnce(); }); it('creates a new event instance for the host dispatch', () => { + class Base extends EventTarget { + get target() { + return null; + } + } + const Mixed = TestMediaMixin(Base as unknown as Constructor); const host = new Mixed(); const hostEvents: Event[] = []; host.addEventListener('custom', (e) => hostEvents.push(e)); + host.attach(new EventTarget()); host.fire(); expect(hostEvents).toHaveLength(1); expect(hostEvents[0]!.type).toBe('custom'); }); - - it('does not forward events when delegate is not an EventTarget', () => { - class PlainDelegate { - attach(_target: EventTarget): void {} - detach(): void {} - } - - const PlainMixed = DelegateMixin(FakeBase, PlainDelegate); - const host = new PlainMixed(); - const handler = vi.fn(); - host.addEventListener('custom', handler); - - expect(handler).not.toHaveBeenCalled(); - }); - }); - - describe('attach order with ProxyMixin', () => { - it('delegate interceptor fires before proxy forwarder when listener added pre-attach', () => { - class InterceptingDelegate extends EventTarget { - attach(target: EventTarget): void { - target.addEventListener('error', (event) => { - event.stopImmediatePropagation(); - this.dispatchEvent(new CustomEvent('error', { detail: 'enriched' })); - }); - } - detach(): void {} - } - - const ProxyBase = ProxyMixin(EventTarget); - const Mixed = DelegateMixin(ProxyBase, InterceptingDelegate); - - const host = new Mixed(); - const handler = vi.fn(); - host.addEventListener('error', handler); - - const target = new EventTarget(); - host.attach(target); - - target.dispatchEvent(new Event('error')); - - expect(handler).toHaveBeenCalledOnce(); - const event = handler.mock.calls[0]![0] as CustomEvent; - expect(event.detail).toBe('enriched'); - }); }); }); diff --git a/packages/core/src/core/media/tests/proxy.test.ts b/packages/core/src/core/media/tests/proxy.test.ts deleted file mode 100644 index 16cdd9b2..00000000 --- a/packages/core/src/core/media/tests/proxy.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { ProxyMixin } from '../proxy'; - -const MediaProxy = ProxyMixin(EventTarget); - -function setup() { - const proxy = new MediaProxy(); - const target = new EventTarget(); - proxy.attach(target); - return { proxy, target }; -} - -describe('ProxyMixin', () => { - describe('event proxying', () => { - it('forwards events from target to proxy listeners', () => { - const { proxy, target } = setup(); - const handler = vi.fn(); - - proxy.addEventListener('play', handler); - target.dispatchEvent(new Event('play')); - - expect(handler).toHaveBeenCalledOnce(); - }); - - it('does not forward after removeEventListener', () => { - const { proxy, target } = setup(); - const handler = vi.fn(); - - proxy.addEventListener('play', handler); - proxy.removeEventListener('play', handler); - target.dispatchEvent(new Event('play')); - - expect(handler).not.toHaveBeenCalled(); - }); - }); - - describe('multiple listeners for the same event type', () => { - it('keeps forwarding to remaining listeners after one is removed', () => { - const { proxy, target } = setup(); - const handlerA = vi.fn(); - const handlerB = vi.fn(); - - proxy.addEventListener('play', handlerA); - proxy.addEventListener('play', handlerB); - proxy.removeEventListener('play', handlerA); - - target.dispatchEvent(new Event('play')); - - expect(handlerA).not.toHaveBeenCalled(); - expect(handlerB).toHaveBeenCalledOnce(); - }); - - it('removes target listener only when all listeners for a type are removed', () => { - const { proxy, target } = setup(); - const handlerA = vi.fn(); - const handlerB = vi.fn(); - - proxy.addEventListener('play', handlerA); - proxy.addEventListener('play', handlerB); - proxy.removeEventListener('play', handlerA); - proxy.removeEventListener('play', handlerB); - - target.dispatchEvent(new Event('play')); - - expect(handlerA).not.toHaveBeenCalled(); - expect(handlerB).not.toHaveBeenCalled(); - }); - - it('handles interleaved add/remove across multiple types', () => { - const { proxy, target } = setup(); - const playHandler = vi.fn(); - const pauseHandler = vi.fn(); - - proxy.addEventListener('play', playHandler); - proxy.addEventListener('pause', pauseHandler); - proxy.removeEventListener('play', playHandler); - - target.dispatchEvent(new Event('play')); - target.dispatchEvent(new Event('pause')); - - expect(playHandler).not.toHaveBeenCalled(); - expect(pauseHandler).toHaveBeenCalledOnce(); - }); - }); - - describe('once listeners', () => { - it('invokes a once listener exactly once', () => { - const { proxy, target } = setup(); - const handler = vi.fn(); - - proxy.addEventListener('play', handler, { once: true }); - target.dispatchEvent(new Event('play')); - target.dispatchEvent(new Event('play')); - - expect(handler).toHaveBeenCalledOnce(); - }); - - it('does not break other listeners when a once listener fires', () => { - const { proxy, target } = setup(); - const onceHandler = vi.fn(); - const persistentHandler = vi.fn(); - - proxy.addEventListener('play', onceHandler, { once: true }); - proxy.addEventListener('play', persistentHandler); - - target.dispatchEvent(new Event('play')); - target.dispatchEvent(new Event('play')); - - expect(onceHandler).toHaveBeenCalledOnce(); - expect(persistentHandler).toHaveBeenCalledTimes(2); - }); - }); - - describe('attach / detach with existing listeners', () => { - it('re-subscribes existing types on the new target after attach', () => { - const proxy = new MediaProxy(); - const handler = vi.fn(); - - proxy.addEventListener('play', handler); - - const target = new EventTarget(); - proxy.attach(target); - - target.dispatchEvent(new Event('play')); - expect(handler).toHaveBeenCalledOnce(); - }); - - it('unsubscribes all types from old target on detach', () => { - const { proxy, target } = setup(); - const handler = vi.fn(); - - proxy.addEventListener('play', handler); - proxy.detach(); - - target.dispatchEvent(new Event('play')); - expect(handler).not.toHaveBeenCalled(); - }); - - it('transfers listeners when switching targets', () => { - const { proxy, target: oldTarget } = setup(); - const handler = vi.fn(); - - proxy.addEventListener('play', handler); - proxy.detach(); - - const newTarget = new EventTarget(); - proxy.attach(newTarget); - - oldTarget.dispatchEvent(new Event('play')); - expect(handler).not.toHaveBeenCalled(); - - newTarget.dispatchEvent(new Event('play')); - expect(handler).toHaveBeenCalledOnce(); - }); - }); - - describe('prototype chain walking', () => { - it('proxies methods inherited from ancestor prototypes', () => { - class Grandparent extends EventTarget { - inherited() { - return 'grandparent'; - } - } - - class Parent extends Grandparent { - direct() { - return 'parent'; - } - } - - const ParentProxy = ProxyMixin(Parent); - const proxy = new ParentProxy(); - const target = new Parent(); - proxy.attach(target); - - expect(proxy.direct()).toBe('parent'); - expect(proxy.inherited()).toBe('grandparent'); - }); - - it('stops before prototypes the proxy already extends', () => { - class Child extends EventTarget { - custom() { - return 'custom'; - } - } - - const ChildProxy = ProxyMixin(Child); - - // addEventListener is defined by the proxy itself — the walk - // should not overwrite it with a forwarding hook. - expect(Object.getOwnPropertyDescriptor(ChildProxy.prototype, 'addEventListener')?.value).toBe( - ChildProxy.prototype.addEventListener - ); - }); - }); - - describe('EventListenerObject support', () => { - it('invokes handleEvent on an object listener', () => { - const { proxy, target } = setup(); - const obj = { handleEvent: vi.fn() }; - - proxy.addEventListener('play', obj); - target.dispatchEvent(new Event('play')); - - expect(obj.handleEvent).toHaveBeenCalledOnce(); - }); - - it('invokes handleEvent for once object listeners', () => { - const { proxy, target } = setup(); - const obj = { handleEvent: vi.fn() }; - - proxy.addEventListener('play', obj, { once: true }); - target.dispatchEvent(new Event('play')); - target.dispatchEvent(new Event('play')); - - expect(obj.handleEvent).toHaveBeenCalledOnce(); - }); - }); -}); diff --git a/packages/core/src/core/media/types.ts b/packages/core/src/core/media/types.ts index ebba17f6..f2aff270 100644 --- a/packages/core/src/core/media/types.ts +++ b/packages/core/src/core/media/types.ts @@ -1,16 +1,242 @@ -// Detects readonly vs writable properties via conditional type identity check. -type IfEquals = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? A : B; +export interface EventLike { + readonly type: string; + readonly timeStamp: number; + readonly detail?: Detail; +} -type WritableKeys = { - [K in keyof T]-?: IfEquals<{ [Q in K]: T[K] }, { -readonly [Q in K]: T[K] }, K, never>; -}[keyof T]; +export interface EventTargetLike { + addEventListener( + type: K, + listener: (event: Events[K]) => void, + options?: { signal?: AbortSignal } + ): void; + removeEventListener(type: K, listener: (event: Events[K]) => void): void; + dispatchEvent(event: EventLike): boolean; +} -type SettableKeys = { - [K in WritableKeys]: T[K] extends (...args: any[]) => any ? never : K; -}[WritableKeys]; +export function TypedEventTarget() { + return EventTarget as unknown as { new (): EventTargetLike }; +} -type ExcludeInternal = K extends `_${string}` ? never : K; +export interface MediaPlaybackEvents { + play: EventLike; + playing: EventLike; + waiting: EventLike; +} -export type InferDelegateProps any> = Partial< - Pick, ExcludeInternal>>> ->; +export interface MediaPlaybackCapability { + play(): Promise; +} + +export interface MediaPauseEvents { + pause: EventLike; + ended: EventLike; +} + +export interface MediaPauseCapability { + pause(): void; + readonly paused: boolean; + readonly ended: boolean; +} + +export interface MediaSeekEvents { + timeupdate: EventLike; + durationchange: EventLike; + seeking: EventLike; + seeked: EventLike; + loadedmetadata: EventLike; +} + +export interface MediaSeekCapability { + currentTime: number; + readonly duration: number; + readonly seeking: boolean; +} + +export interface MediaSourceEvents { + loadstart: EventLike; + emptied: EventLike; + canplay: EventLike; + canplaythrough: EventLike; + loadeddata: EventLike; +} + +export interface MediaSourceCapability { + src: string; + readonly currentSrc: string; + readonly readyState: MediaReadyStateValue | number; + load(): void; +} + +const MediaReadyState = { + HAVE_NOTHING: 0, + HAVE_METADATA: 1, + HAVE_CURRENT_DATA: 2, + HAVE_FUTURE_DATA: 3, + HAVE_ENOUGH_DATA: 4, +} as const; + +export type MediaReadyStateValue = (typeof MediaReadyState)[keyof typeof MediaReadyState]; + +export type MediaFeatureAvailability = 'available' | 'unavailable' | 'unsupported'; + +export interface MediaVolumeEvents { + volumechange: EventLike; +} + +export interface MediaVolumeCapability { + volume: number; + muted: boolean; +} + +export interface MediaPlaybackRateEvents { + ratechange: EventLike; +} + +export interface MediaPlaybackRateCapability { + playbackRate: number; +} + +export interface MediaBufferEvents { + progress: EventLike; +} + +export interface TimeRangeLike { + readonly length: number; + start(index: number): number; + end(index: number): number; +} + +export interface MediaBufferCapability { + readonly buffered: TimeRangeLike; + readonly seekable: TimeRangeLike; +} + +export interface MediaErrorEvents { + error: EventLike; +} + +export interface ErrorLike { + readonly code: number; + readonly message: string; +} + +export interface MediaErrorCapability { + readonly error: ErrorLike | null; +} + +export interface TextCueLike { + readonly startTime: number; + readonly endTime: number; + readonly text?: string; +} + +export interface TextCueListLike { + readonly length: number; + [Symbol.iterator](): Iterator; + getCueById?(id: string): TextCueLike | null; +} + +export interface TextTrackLike { + readonly kind: string; + readonly label: string; + readonly language: string; + readonly id: string; + readonly src?: string; + mode: 'showing' | 'disabled' | 'hidden'; + readonly cues: TextCueListLike | null; + addCue?(cue: TextCueLike): void; +} + +export interface TextTrackListEvents { + addtrack: EventLike; + removetrack: EventLike; + changetrack: EventLike; + trackmodechange: EventLike; +} + +export interface TextTrackListLike { + readonly length: number; + readonly [index: number]: TextTrackLike; + [Symbol.iterator](): Iterator; + getTrackById?(id: string): TextTrackLike | null; +} + +export interface MediaTextTrackCapability { + readonly textTracks: TextTrackListLike; +} + +export interface MediaFullscreenCapability { + requestFullscreen(): Promise; +} + +export interface MediaPictureInPictureCapability { + requestPictureInPicture(): Promise; +} + +interface MediaEvents extends MediaPlaybackEvents {} + +export interface Media extends MediaPlaybackCapability, EventTargetLike { + readonly engine?: unknown; + readonly target?: unknown; +} + +export interface VideoEvents + extends MediaPlaybackEvents, + MediaPauseEvents, + MediaSeekEvents, + MediaSourceEvents, + MediaVolumeEvents, + MediaPlaybackRateEvents, + MediaBufferEvents, + MediaErrorEvents, + TextTrackListEvents {} + +export interface Video + extends MediaPlaybackCapability, + MediaPauseCapability, + MediaSeekCapability, + MediaSourceCapability, + MediaVolumeCapability, + MediaPlaybackRateCapability, + MediaBufferCapability, + MediaErrorCapability, + MediaTextTrackCapability, + MediaFullscreenCapability, + MediaPictureInPictureCapability, + EventTargetLike { + readonly engine?: unknown; + readonly target?: unknown; +} + +export interface AudioEvents + extends MediaPlaybackEvents, + MediaPauseEvents, + MediaSeekEvents, + MediaSourceEvents, + MediaVolumeEvents, + MediaPlaybackRateEvents, + MediaBufferEvents, + MediaErrorEvents {} + +export interface Audio + extends MediaPlaybackCapability, + MediaPauseCapability, + MediaSeekCapability, + MediaSourceCapability, + MediaVolumeCapability, + MediaPlaybackRateCapability, + MediaBufferCapability, + MediaErrorCapability, + EventTargetLike { + readonly engine?: unknown; + readonly target?: unknown; +} + +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/core/ui/volume-slider/volume-slider-core.ts b/packages/core/src/core/ui/volume-slider/volume-slider-core.ts index 809938c0..0e77e7b9 100644 --- a/packages/core/src/core/ui/volume-slider/volume-slider-core.ts +++ b/packages/core/src/core/ui/volume-slider/volume-slider-core.ts @@ -1,7 +1,7 @@ import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; - -import type { MediaFeatureAvailability, MediaVolumeState } from '../../media/state'; +import type { MediaVolumeState } from '../../media/state'; +import type { MediaFeatureAvailability } from '../../media/types'; import { SliderCore, type SliderProps, type SliderState } from '../slider/slider-core'; export interface VolumeSliderProps extends SliderProps { diff --git a/packages/core/src/core/utils/define-class-prop-hooks.ts b/packages/core/src/core/utils/define-class-prop-hooks.ts deleted file mode 100644 index 0556b85b..00000000 --- a/packages/core/src/core/utils/define-class-prop-hooks.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Constructor } from '@videojs/utils/types'; - -export function defineClassPropHooks>(Class: T, BaseClassProto: PropertyDescriptorMap) { - for (const prop of Object.getOwnPropertyNames(BaseClassProto)) { - if (prop in Class.prototype || prop.startsWith('_')) continue; - - const descriptor = Object.getOwnPropertyDescriptor(BaseClassProto, prop); - if (!descriptor) continue; - - const config: PropertyDescriptor = {}; - if (typeof descriptor.value === 'function') { - config.value = function (this: InstanceType, ...args: any[]) { - return this.call?.(prop, ...args); - }; - } else if (descriptor.get) { - config.get = function (this: InstanceType) { - return this.get?.(prop); - }; - - if (descriptor.set) { - config.set = function (this: InstanceType, val: any) { - this.set?.(prop, val); - }; - } - } - - Object.defineProperty(Class.prototype, prop, config); - } -} diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index deb627c0..828c6b15 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -7,7 +7,6 @@ export * from './hotkey/actions'; export * from './hotkey/aria'; export * from './hotkey/coordinator'; export * from './hotkey/hotkey'; -export * from './media/proxy'; export * from './media/types'; export * from './store/features'; export * from './store/selectors'; diff --git a/packages/core/src/dom/media/audio-host.ts b/packages/core/src/dom/media/audio-host.ts new file mode 100644 index 00000000..837c1773 --- /dev/null +++ b/packages/core/src/dom/media/audio-host.ts @@ -0,0 +1,4 @@ +import type { Audio, AudioEvents } from '../../core/media/types'; +import { HTMLMediaElementHost } from './media-host'; + +export class HTMLAudioElementHost extends HTMLMediaElementHost implements Audio {} 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 c86df731..e321ca82 100644 --- a/packages/core/src/dom/media/custom-media-element/index.ts +++ b/packages/core/src/dom/media/custom-media-element/index.ts @@ -1,66 +1,7 @@ -/** - * Custom Media Element - * Based on https://github.com/muxinc/custom-video-element - Mux - MIT License - * - * The goal is to create an element that works just like the video element - * but can be extended/sub-classed, because native elements cannot be - * extended today across browsers. - */ - -// The onevent-like props are weirdly set on the HTMLElement prototype with other -// generic events making it impossible to pick these specific to HTMLMediaElement. -export const Events = [ - 'abort', - 'canplay', - 'canplaythrough', - 'durationchange', - 'emptied', - 'encrypted', - 'ended', - 'error', - 'loadeddata', - 'loadedmetadata', - 'loadstart', - 'pause', - 'play', - 'playing', - 'progress', - 'ratechange', - 'seeked', - 'seeking', - 'stalled', - 'suspend', - 'timeupdate', - 'volumechange', - 'waiting', - 'waitingforkey', - 'resize', - 'enterpictureinpicture', - 'leavepictureinpicture', - 'webkitbeginfullscreen', - 'webkitendfullscreen', - 'webkitpresentationmodechanged', -] as const; - -export type EventsMap = { - [key in (typeof Events)[number]]: CustomEvent; -}; - -export const Attributes = [ - 'autopictureinpicture', - 'disablepictureinpicture', - 'disableremoteplayback', - 'autoplay', - 'controls', - 'controlslist', - 'crossorigin', - 'loop', - 'muted', - 'playsinline', - 'poster', - 'preload', - 'src', -] as const; +import { namedNodeMapToObject, serializeAttributes } from '@videojs/utils/dom'; +import { omit, pick } from '@videojs/utils/object'; +import { kebabCase } from '@videojs/utils/string'; +import type { Constructor } from '@videojs/utils/types'; /** CSS custom property names for video elements. */ export const VideoCSSVars = { @@ -81,33 +22,7 @@ export const VideoCSSVars = { /** CSS custom property names for audio elements. */ export const AudioCSSVars = {} as const; -/** - * Helper function to generate the HTML template for audio elements. - */ -function getAudioTemplateHTML(attrs: Record): string { - return /*html*/ ` - - - - - - `; -} - -/** - * Helper function to generate the HTML template for video elements. - */ +/** Helper function to generate the HTML template for video elements. */ function getVideoTemplateHTML(attrs: Record): string { return /*html*/ ` + + <${tag}${serializeAttributes(attrs)}> + + + `; + }; +} + +const excludedProperties = ['attach', 'detach', 'destroy']; + +interface MediaHost extends EventTarget { + readonly target: EventTarget | null; + attach(target: EventTarget | null): void; + detach(): void; + destroy(): void; + /** Index signature for dynamic property forwarding. */ + [key: string]: any; +} + +type MediaHostConstructor = Constructor & { + observedAttributes?: string[]; }; -type MediaChild = HTMLTrackElement | HTMLSourceElement; - -declare class CustomAudioElementClass extends HTMLAudioElement implements HTMLAudioElement { - static readonly observedAttributes: string[]; - static getTemplateHTML: typeof getAudioTemplateHTML; - static shadowRootOptions: ShadowRootInit; - static Events: string[]; - readonly target: HTMLAudioElement; - attributeChangedCallback(attrName: string, oldValue?: string | null, newValue?: string | null): void; - connectedCallback(): void; - disconnectedCallback(): void; - init(): void; - handleEvent(event: Event): void; -} - -declare class CustomVideoElementClass extends HTMLVideoElement implements HTMLVideoElement { - static readonly observedAttributes: string[]; - static getTemplateHTML: typeof getVideoTemplateHTML; - static shadowRootOptions: ShadowRootInit; - static Events: string[]; - readonly target: HTMLVideoElement; - attributeChangedCallback(attrName: string, oldValue?: string | null, newValue?: string | null): void; - connectedCallback(): void; - disconnectedCallback(): void; - init(): void; - handleEvent(event: Event): void; -} - -type CustomMediaElementConstructor = { - readonly observedAttributes: string[]; - getTemplateHTML: typeof getVideoTemplateHTML | typeof getAudioTemplateHTML; +type CustomMediaConstructor = Constructor> & { + properties: Record; + getTemplateHTML: (attrs: Record) => string; shadowRootOptions: ShadowRootInit; - Events: string[]; - new (...args: any[]): T; + readonly observedAttributes: string[]; }; -export type CustomVideoElement = CustomMediaElementConstructor; -export type CustomAudioElement = CustomMediaElementConstructor; +export function CustomMediaElement( + tag: string, + MediaHost: T +): CustomMediaConstructor { + const mediaHostAttrToProp = new Map(); + let isDefined = false; -/** - * @see https://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/ - */ -export function CustomMediaMixin>( - superclass: T, - { tag, is }: { tag: 'video'; is?: string } -): CustomVideoElement; -export function CustomMediaMixin>( - superclass: T, - { tag, is }: { tag: 'audio'; is?: string } -): CustomAudioElement; -export function CustomMediaMixin>( - superclass: T, - { tag, is }: { tag: 'audio' | 'video'; is?: string } -): any { - // `is` makes it possible to extend a custom built-in. e.g., castable-video - const nativeElTest = globalThis.document?.createElement?.(tag, { is } as any); - const nativeElProps = nativeElTest ? getNativeElProps(nativeElTest) : []; - - return class CustomMedia extends superclass { - static getTemplateHTML = tag.endsWith('audio') ? getAudioTemplateHTML : getVideoTemplateHTML; + class CustomMedia extends (globalThis.HTMLElement ?? class {}) { + static getTemplateHTML = tag.endsWith('video') ? getVideoTemplateHTML : getCommonTemplateHTML(tag); static shadowRootOptions: ShadowRootInit = { mode: 'open' }; - static Events = Events; - static #isDefined = false; - static #propsToAttrs: Set; + static properties = { + autoPictureInPicture: { type: Boolean }, + autoplay: { type: Boolean }, + controls: { type: Boolean }, + controlsList: { type: String }, + crossOrigin: { type: String }, + defaultMuted: { type: Boolean, attribute: 'muted' }, + disablePictureInPicture: { type: Boolean }, + disableRemotePlayback: { type: Boolean }, + loading: { type: String }, + loop: { type: Boolean }, + playsInline: { type: Boolean }, + poster: { type: String }, + preload: { type: String }, + src: { type: String }, + }; static get observedAttributes() { - CustomMedia.#define(); - - // Include any attributes from the custom built-in. - // @ts-expect-error - const natAttrs = nativeElTest?.constructor?.observedAttributes ?? []; - - return [...natAttrs, ...Attributes]; + // biome-ignore lint/complexity/noThisInStatic: resolves to the subclass that may override `properties` + CustomMedia.#define(this); + return [ + // biome-ignore lint/complexity/noThisInStatic: intentional use of this + ...getAttrsFromProps(this.properties), + ...(MediaHost.observedAttributes ?? []), + ]; } - static #define(): void { - if (CustomMedia.#isDefined) return; - CustomMedia.#isDefined = true; + static #define(ctor: typeof CustomMedia) { + if (isDefined) return; + isDefined = true; - CustomMedia.#propsToAttrs = new Set(CustomMedia.observedAttributes); - // defaultMuted maps to the muted attribute, handled manually below. - CustomMedia.#propsToAttrs.delete('muted'); + const Attributes = getAttrsFromProps(ctor.properties); - // Passthrough native element functions from the custom element to the native element - for (const prop of nativeElProps) { - if (prop in CustomMedia.prototype) continue; + 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; + + const descriptor = Object.getOwnPropertyDescriptor(proto, prop); + if (!descriptor) continue; - if (typeof nativeElTest[prop] === 'function') { - // Function - // @ts-expect-error - CustomMedia.prototype[prop] = function (...args: any[]) { - this.#init(); - return this.call(prop, ...args); - }; - } else { - // Getter and setter configuration const config: PropertyDescriptor = { - get(this: CustomMedia) { - this.#init(); - return this.get(prop); - }, + enumerable: true, + configurable: true, }; - if (prop !== prop.toUpperCase()) { - config.set = function (this: CustomMedia, val: any) { - this.#init(); - this.set(prop, val); + if (typeof descriptor.value === 'function') { + config.value = function (this: CustomMedia, ...args: any[]) { + return this.#mediaHost[prop](...args); }; + } else if (descriptor.get) { + config.get = function (this: CustomMedia) { + return this.#mediaHost[prop]; + }; + + if (descriptor.set) { + const attr = kebabCase(prop); + // If explicitly observed by the media host, or it's a native attribute, + // route through attributeChangedCallback. + if (MediaHost.observedAttributes?.includes(attr) || Attributes.includes(attr)) { + mediaHostAttrToProp.set(attr, prop); + + config.set = function (this: CustomMedia, val: any) { + if (val === true || val === false || val == null) { + this.toggleAttribute(attr, Boolean(val)); + } else { + this.setAttribute(attr, String(val)); + } + }; + } else { + config.set = function (this: CustomMedia, val: any) { + this.#mediaHost[prop] = val; + }; + } + } } Object.defineProperty(CustomMedia.prototype, prop, config); } } + + const properties = ctor.properties as Record; + for (const [prop, { type, attribute }] of Object.entries(properties)) { + if (prop in CustomMedia.prototype) continue; + + const attr = attribute ?? prop.toLowerCase(); + Object.defineProperty(CustomMedia.prototype, prop, { + get: function (this: CustomMedia) { + return type === Boolean ? this.hasAttribute(attr) : this.getAttribute(attr); + }, + set: function (this: CustomMedia, val: any) { + if (type === Boolean) { + this.toggleAttribute(attr, Boolean(val)); + } else { + this.setAttribute(attr, val); + } + }, + enumerable: true, + configurable: true, + }); + } } - // Private fields - #isInit = false; - #target: HTMLVideoElement | HTMLAudioElement | null = null; - #childMap = new Map(); + #mediaHost: MediaHost; + #bridgedEventTypes = new Set(); + #childMap = new Map(); #childObserver?: MutationObserver; - get(prop: string): any { - const attr = prop.toLowerCase(); - if (CustomMedia.#propsToAttrs.has(attr)) { - const val = this.getAttribute(attr); - return val === null ? false : val === '' ? true : val; - } - return this.target?.[prop as keyof typeof this.target]; - } + constructor() { + super(); - set(prop: string, val: any): void { - const attr = prop.toLowerCase(); - if (CustomMedia.#propsToAttrs.has(attr)) { - if (val === true || val === false || val == null) { - this.toggleAttribute(attr, Boolean(val)); - } else { - this.setAttribute(attr, val); - } - return; + if (!this.shadowRoot) { + const ctor = this.constructor as typeof CustomMedia; + this.attachShadow(ctor.shadowRootOptions); + + const allowedKeys = getAttrsFromProps(ctor.properties); + const disallowedKeys = [...mediaHostAttrToProp.keys()]; + const attrs: Record = omit( + pick(namedNodeMapToObject(this.attributes), allowedKeys), + disallowedKeys + ); + if (tag && !attrs.part) attrs.part = tag; + this.shadowRoot!.innerHTML = ctor.getTemplateHTML(attrs); } - if (this.target) { - // @ts-expect-error - this.target[prop as keyof typeof this.target] = val; - } + this.#mediaHost = new MediaHost(); + this.#attachToTarget(); + + this.#childObserver = new MutationObserver(this.#syncMediaChildAttribute.bind(this)); + this.shadowRoot!.addEventListener('slotchange', () => { + this.#attachToTarget(); + this.#syncMediaChildren(); + }); + + this.#syncMediaChildren(); } - call(prop: string, ...args: any[]): any { - const nativeFn = this.target?.[prop as keyof typeof this.target] as ((...args: any[]) => any) | undefined; - return nativeFn?.apply(this.target, args); + #attachToTarget(): void { + const target = this.target; + if (target === this.#mediaHost.target) return; + if (this.#mediaHost.target) this.#mediaHost.detach(); + this.#mediaHost.attach(target); } - // If the custom element is defined before the custom element's HTML is parsed - // no attributes will be available in the constructor (construction process). - // Wait until initializing in the attributeChangedCallback or - // connectedCallback or accessing any properties. - - get target() { - this.#init(); + get target(): HTMLVideoElement | HTMLAudioElement | null { return ( - this.#target ?? this.querySelector(':scope > [slot=media]') ?? this.querySelector(tag) ?? this.shadowRoot?.querySelector(tag) ?? @@ -312,101 +263,103 @@ export function CustomMediaMixin>( ); } - set target(val: HTMLVideoElement | HTMLAudioElement | null) { - this.#target = val; - } - - get defaultMuted() { - this.#init(); - return this.get('muted'); - } - - set defaultMuted(val) { - this.#init(); - this.set('muted', val); - } - - #init(): void { - if (this.#isInit) return; - this.#isInit = true; - this.init(); - } - - init(): void { - if (!this.shadowRoot) { - this.attachShadow({ mode: 'open' }); - - const attrs = namedNodeMapToObject(this.attributes); - if (is) attrs.is = is; - if (tag) attrs.part = tag; - this.shadowRoot!.innerHTML = (this.constructor as typeof CustomMedia).getTemplateHTML(attrs); - } - - // Neither Chrome or Firefox support setting the muted attribute - // after using document.createElement. - // Get around this by setting the muted property manually. - this.target!.muted = this.hasAttribute('muted'); - - for (const prop of nativeElProps) { - // @ts-expect-error - this.#upgradeProperty(prop); - } - - this.#childObserver = new MutationObserver(this.#syncMediaChildAttribute.bind(this)); - this.shadowRoot!.addEventListener('slotchange', () => this.#syncMediaChildren()); - this.#syncMediaChildren(); - - // Media element events don't bubble so we need to capture them on the shadow root. - for (const type of (this.constructor as typeof CustomMedia).Events) { - this.shadowRoot!.addEventListener(type, this.#deferForwardEvent, true); + disconnectedCallback(): void { + if (!this.hasAttribute('keep-alive')) { + this.#mediaHost.destroy(); } } - #deferForwardEvent = (event: Event) => { - if (this.target && this.target === event.target) { - // Add an event listener on the bubbling phase that forwards the event - // so consumers can still stop propagation of the event. - this.target.addEventListener(event.type, this.#forwardEvent, { once: true }); + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | ((event: never) => void) | null, + options?: boolean | AddEventListenerOptions + ) { + super.addEventListener(type, listener as EventListener, options); + if (!this.#bridgedEventTypes.has(type)) { + this.#bridgedEventTypes.add(type); + this.#mediaHost.addEventListener(type, this.#bridgeEvent); + } + } + + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject | ((event: never) => void) | null, + options?: boolean | EventListenerOptions + ): void { + super.removeEventListener(type, listener as EventListener, options); + } + + #bridgeEvent = (event: Event) => { + if (!event.composed) { + this.dispatchEvent(new (event.constructor as typeof Event)(event.type, event)); } }; - #forwardEvent = (event: Event) => { - this.dispatchEvent(new (event.constructor as typeof Event)(event.type, event)); - }; + attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string | null): void { + const prop = mediaHostAttrToProp.get(attrName); + if (prop) { + if (oldValue !== newValue) { + const valueType = typeof this.#mediaHost[prop]; + this.#mediaHost[prop] = + valueType === 'boolean' ? newValue !== null : valueType === 'number' ? Number(newValue) : (newValue ?? ''); + } + return; + } + + if ( + !CustomMedia.observedAttributes.includes(attrName) && + (this.constructor as typeof CustomMedia).observedAttributes.includes(attrName) + ) { + return; + } + + if (newValue === null) { + this.target?.removeAttribute(attrName); + } else if (this.target?.getAttribute(attrName) !== newValue) { + this.target?.setAttribute(attrName, newValue); + } + } #syncMediaChildren(): void { - const removeNativeChildren = new Map(this.#childMap); const defaultSlot = this.shadowRoot?.querySelector('slot:not([name])') as HTMLSlotElement; + const mediaChildren = new Set( + defaultSlot + ?.assignedElements({ flatten: true }) + .filter((el) => el.localName === 'track' || el.localName === 'source') as ( + | HTMLTrackElement + | HTMLSourceElement + )[] + ); - const mediaChildren = defaultSlot - ?.assignedElements({ flatten: true }) - .filter((el) => ['track', 'source'].includes(el.localName)) as MediaChild[]; + for (const [el, clone] of this.#childMap) { + if (!mediaChildren.has(el)) { + clone.remove(); + this.#childMap.delete(el); + } + } - mediaChildren.forEach((el) => { - removeNativeChildren.delete(el); + for (const el of mediaChildren) { let clone = this.#childMap.get(el); if (!clone) { - clone = el.cloneNode() as MediaChild; + clone = el.cloneNode() as HTMLTrackElement | HTMLSourceElement; this.#childMap.set(el, clone); this.#childObserver?.observe(el, { attributes: true }); } this.target?.append(clone); this.#enableDefaultTrack(clone as HTMLTrackElement); - }); - - removeNativeChildren.forEach((clone, el) => { - clone.remove(); - this.#childMap.delete(el); - }); + } } #syncMediaChildAttribute(mutations: MutationRecord[]): void { for (const mutation of mutations) { if (mutation.type === 'attributes') { const { target, attributeName } = mutation; - const clone = this.#childMap.get(target as MediaChild); + const clone = this.#childMap.get(target as HTMLTrackElement | HTMLSourceElement); if (clone && attributeName) { - clone.setAttribute(attributeName, (target as MediaChild).getAttribute(attributeName) ?? ''); + clone.setAttribute( + attributeName, + (target as HTMLTrackElement | HTMLSourceElement).getAttribute(attributeName) ?? '' + ); this.#enableDefaultTrack(clone as HTMLTrackElement); } } @@ -426,95 +379,11 @@ export function CustomMediaMixin>( trackEl.track.mode = 'hidden'; } } - - #upgradeProperty(this: typeof nativeElTest, prop: keyof typeof nativeElTest) { - // Sets properties that are set before the custom element is upgraded. - // https://web.dev/custom-elements-best-practices/#make-properties-lazy - if (Object.hasOwn(this, prop)) { - const value = this[prop]; - // Delete the set property from this instance. - delete this[prop]; - // Set the value again via the (prototype) setter on this class. - // @ts-expect-error - this[prop] = value; - } - } - - attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string | null): void { - this.#init(); - this.#forwardAttribute(attrName, oldValue, newValue); - } - - #forwardAttribute(attrName: string, _oldValue: string | null, newValue: string | null): void { - if (['id', 'class'].includes(attrName)) return; - - if ( - !CustomMedia.observedAttributes.includes(attrName) && - (this.constructor as typeof CustomMedia).observedAttributes.includes(attrName) - ) { - return; - } - - if (newValue === null) { - this.target?.removeAttribute(attrName); - } else if (this.target?.getAttribute(attrName) !== newValue) { - this.target?.setAttribute(attrName, newValue); - } - } - - connectedCallback(): void { - this.#init(); - } - }; -} - -/** - * Helper function to get all properties from a native media element's prototype. - */ -function getNativeElProps(nativeElTest: HTMLVideoElement | HTMLAudioElement) { - const nativeElProps: (keyof typeof nativeElTest)[] = []; - for ( - let proto = Object.getPrototypeOf(nativeElTest); - proto && proto !== HTMLElement.prototype; - proto = Object.getPrototypeOf(proto) - ) { - const props = Object.getOwnPropertyNames(proto) as (keyof typeof nativeElTest)[]; - nativeElProps.push(...props); } - return nativeElProps; + + return CustomMedia as any; } -/** - * Helper function to serialize attributes into a string. - */ -function serializeAttributes(attrs: Record): string { - let html = ''; - for (const key in attrs) { - // Skip forwarding non native video attributes. - if (!Attributes.includes(key as (typeof Attributes)[number])) continue; - - const value = attrs[key]; - if (value === '') html += ` ${key}`; - else html += ` ${key}="${value}"`; - } - return html; +function getAttrsFromProps(props: Record): string[] { + return Object.keys(props).map((prop) => props[prop]?.attribute ?? prop.toLowerCase()); } - -/** - * Helper function to convert NamedNodeMap to a plain object. - */ -function namedNodeMapToObject(namedNodeMap: NamedNodeMap): Record { - const obj: Record = {}; - for (const attr of namedNodeMap) { - obj[attr.name] = attr.value; - } - return obj; -} - -export const CustomVideoElement = CustomMediaMixin(globalThis.HTMLElement ?? class {}, { - tag: 'video', -}); - -export const CustomAudioElement = CustomMediaMixin(globalThis.HTMLElement ?? class {}, { - tag: 'audio', -}); 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 new file mode 100644 index 00000000..791b12bb --- /dev/null +++ b/packages/core/src/dom/media/custom-media-element/tests/custom-media-element.test.ts @@ -0,0 +1,934 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { HTMLAudioElementHost } from '../../audio-host'; +import { HTMLVideoElementHost } from '../../video-host'; +import { CustomMediaElement } from '../index'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +class TestVideoHost extends HTMLVideoElementHost { + #src = ''; + #destroyed = false; + + get src() { + return this.#src; + } + + set src(value: string) { + this.#src = value; + } + + get destroyed() { + return this.#destroyed; + } + + destroy() { + this.#destroyed = true; + } +} + +class TestVideoHostWithObjects extends HTMLVideoElementHost { + #src = ''; + #config: Record = {}; + #metadata: Record | undefined; + #debug = false; + + get src() { + return this.#src; + } + + set src(value: string) { + this.#src = value; + } + + get config() { + return this.#config; + } + + set config(value: Record) { + this.#config = value; + } + + get metadata() { + return this.#metadata; + } + + set metadata(value: Record | undefined) { + this.#metadata = value; + } + + get debug() { + return this.#debug; + } + + set debug(value: boolean) { + this.#debug = value; + } + + destroy() {} +} + +class TestAudioHost extends HTMLAudioElementHost { + destroy() {} +} + +let tagCounter = 0; + +function defineVideoElement() { + const tag = `test-video-${++tagCounter}`; + const Ctor = CustomMediaElement('video', TestVideoHost); + customElements.define(tag, Ctor); + return { Ctor, tag }; +} + +function defineVideoElementWithObjects() { + const tag = `test-video-${++tagCounter}`; + const Ctor = CustomMediaElement('video', TestVideoHostWithObjects); + customElements.define(tag, Ctor); + return { Ctor, tag }; +} + +function defineAudioElement() { + const tag = `test-audio-${++tagCounter}`; + const Ctor = CustomMediaElement('audio', TestAudioHost); + customElements.define(tag, Ctor); + return { Ctor, tag }; +} + +function create(def: { Ctor: new () => any; tag: string }) { + const el = new def.Ctor(); + document.body.appendChild(el); + return el; +} + +class TrackingVideoHost extends HTMLVideoElementHost { + calls: string[] = []; + #src = ''; + #volume = 1; + #muted = false; + #currentTime = 0; + #playbackRate = 1; + + get src() { + return this.#src; + } + + set src(value: string) { + this.calls.push(`set:src:${value}`); + this.#src = value; + } + + override get volume() { + return this.#volume; + } + + override set volume(value: number) { + this.calls.push(`set:volume:${value}`); + this.#volume = value; + } + + override get muted() { + return this.#muted; + } + + override set muted(value: boolean) { + this.calls.push(`set:muted:${value}`); + this.#muted = value; + } + + override get currentTime() { + return this.#currentTime; + } + + override set currentTime(value: number) { + this.calls.push(`set:currentTime:${value}`); + this.#currentTime = value; + } + + override get playbackRate() { + return this.#playbackRate; + } + + override set playbackRate(value: number) { + this.calls.push(`set:playbackRate:${value}`); + this.#playbackRate = value; + } + + destroy() {} +} + +function defineTrackingVideoElement() { + const tag = `test-video-${++tagCounter}`; + const Ctor = CustomMediaElement('video', TrackingVideoHost); + customElements.define(tag, Ctor); + return { Ctor, tag }; +} + +describe('CustomMediaElement', () => { + describe('shadow DOM setup', () => { + it('creates a shadow root with a video element for video tag', () => { + const el = create(defineVideoElement()); + expect(el.shadowRoot).toBeTruthy(); + expect(el.shadowRoot!.querySelector('video')).toBeTruthy(); + }); + + it('creates a shadow root with an audio element for audio tag', () => { + const el = create(defineAudioElement()); + expect(el.shadowRoot).toBeTruthy(); + expect(el.shadowRoot!.querySelector('audio')).toBeTruthy(); + }); + + it('sets part attribute on the inner element', () => { + const el = create(defineVideoElement()); + const video = el.shadowRoot!.querySelector('video')!; + expect(video.getAttribute('part')).toBe('video'); + }); + }); + + describe('target', () => { + it('returns the shadow DOM media element by default', () => { + const el = create(defineVideoElement()); + const video = el.shadowRoot!.querySelector('video'); + expect(el.target).toBe(video); + }); + + it('prefers a slotted element with slot=media', () => { + const el = create(defineVideoElement()); + const slotted = document.createElement('video'); + slotted.slot = 'media'; + el.appendChild(slotted); + expect(el.target).toBe(slotted); + }); + + it('re-attaches media host when slotted media element appears after construction', () => { + const el = create(defineVideoElement()); + const shadowVideo = el.shadowRoot!.querySelector('video')!; + + const slottedVideo = document.createElement('video'); + slottedVideo.slot = 'media'; + el.appendChild(slottedVideo); + + // Simulate slotchange with bubbles (matches real browser behavior) + const mediaSlot = el.shadowRoot!.querySelector('slot[name="media"]')!; + mediaSlot.dispatchEvent(new Event('slotchange', { bubbles: true })); + + // After re-attach, property writes should go to the slotted element + el.volume = 0.5; + expect(slottedVideo.volume).toBe(0.5); + expect(shadowVideo.volume).toBe(1); + }); + + it('does not re-attach when the target has not changed', () => { + const el = create(defineVideoElement()); + const shadowVideo = el.shadowRoot!.querySelector('video')!; + + // Dispatch slotchange without adding a slotted element + const mediaSlot = el.shadowRoot!.querySelector('slot[name="media"]')!; + mediaSlot.dispatchEvent(new Event('slotchange', { bubbles: true })); + + el.volume = 0.5; + expect(shadowVideo.volume).toBe(0.5); + }); + }); + + describe('observedAttributes includes standard media attributes', () => { + it('includes standard attributes for video elements', () => { + const { Ctor } = defineVideoElement(); + const observed = Ctor.observedAttributes; + expect(observed).toContain('autoplay'); + expect(observed).toContain('controls'); + expect(observed).toContain('crossorigin'); + expect(observed).toContain('loop'); + expect(observed).toContain('muted'); + expect(observed).toContain('playsinline'); + expect(observed).toContain('preload'); + expect(observed).toContain('src'); + expect(observed).toContain('poster'); + expect(observed).toContain('autopictureinpicture'); + expect(observed).toContain('disablepictureinpicture'); + }); + + it('includes standard attributes for audio elements', () => { + const { Ctor } = defineAudioElement(); + const observed = Ctor.observedAttributes; + expect(observed).toContain('autoplay'); + expect(observed).toContain('controls'); + expect(observed).toContain('crossorigin'); + expect(observed).toContain('loop'); + expect(observed).toContain('muted'); + expect(observed).toContain('preload'); + expect(observed).toContain('src'); + }); + }); + + describe('observedAttributes', () => { + it('includes MediaHost properties that overlap with standard Attributes', () => { + const { Ctor } = defineVideoElement(); + const observed = Ctor.observedAttributes; + expect(observed).toContain('src'); + expect(observed).toContain('muted'); + }); + + it('excludes MediaHost properties not in standard Attributes', () => { + const { Ctor } = defineVideoElement(); + const observed = Ctor.observedAttributes; + expect(observed).not.toContain('current-time'); + expect(observed).not.toContain('volume'); + expect(observed).not.toContain('playback-rate'); + }); + }); + + describe('video attribute forwarding', () => { + it('forwards autoplay to the target video element', () => { + const el = create(defineVideoElement()); + el.setAttribute('autoplay', ''); + expect(el.target!.hasAttribute('autoplay')).toBe(true); + }); + + it('forwards controls to the target video element', () => { + const el = create(defineVideoElement()); + el.setAttribute('controls', ''); + expect(el.target!.hasAttribute('controls')).toBe(true); + }); + + it('forwards crossorigin to the target video element', () => { + const el = create(defineVideoElement()); + el.setAttribute('crossorigin', 'anonymous'); + expect(el.target!.getAttribute('crossorigin')).toBe('anonymous'); + }); + + it('forwards loop to the target video element', () => { + const el = create(defineVideoElement()); + el.setAttribute('loop', ''); + expect(el.target!.hasAttribute('loop')).toBe(true); + }); + + it('forwards playsinline to the target video element', () => { + const el = create(defineVideoElement()); + el.setAttribute('playsinline', ''); + expect(el.target!.hasAttribute('playsinline')).toBe(true); + }); + + it('forwards preload to the target video element', () => { + const el = create(defineVideoElement()); + el.setAttribute('preload', 'none'); + expect(el.target!.getAttribute('preload')).toBe('none'); + }); + + it('forwards poster to the target video element', () => { + const el = create(defineVideoElement()); + el.setAttribute('poster', 'https://example.com/poster.jpg'); + expect(el.target!.getAttribute('poster')).toBe('https://example.com/poster.jpg'); + }); + + it('forwards controlslist to the target video element', () => { + const el = create(defineVideoElement()); + el.setAttribute('controlslist', 'nodownload'); + expect(el.target!.getAttribute('controlslist')).toBe('nodownload'); + }); + + it('forwards disableremoteplayback to the target video element', () => { + const el = create(defineVideoElement()); + el.setAttribute('disableremoteplayback', ''); + expect(el.target!.hasAttribute('disableremoteplayback')).toBe(true); + }); + + it('forwards disablepictureinpicture to the target video element', () => { + const el = create(defineVideoElement()); + el.setAttribute('disablepictureinpicture', ''); + expect(el.target!.hasAttribute('disablepictureinpicture')).toBe(true); + }); + + it('forwards autopictureinpicture to the target video element', () => { + const el = create(defineVideoElement()); + el.setAttribute('autopictureinpicture', ''); + expect(el.target!.hasAttribute('autopictureinpicture')).toBe(true); + }); + + it('forwards loading to the target video element', () => { + const el = create(defineVideoElement()); + el.setAttribute('loading', 'lazy'); + expect(el.target!.getAttribute('loading')).toBe('lazy'); + }); + + it('forwards all non-setter VideoAttributes to the target', () => { + const el = create(defineVideoElement()); + const target = el.target!; + + const booleanAttrs = [ + 'autoplay', + 'controls', + 'disableremoteplayback', + 'loop', + 'playsinline', + 'autopictureinpicture', + 'disablepictureinpicture', + ] as const; + + const valueAttrs = { + controlslist: 'nodownload', + crossorigin: 'anonymous', + loading: 'lazy', + preload: 'auto', + poster: 'https://example.com/poster.jpg', + } as const; + + for (const attr of booleanAttrs) { + el.setAttribute(attr, ''); + } + for (const [attr, value] of Object.entries(valueAttrs)) { + el.setAttribute(attr, value); + } + + for (const attr of booleanAttrs) { + expect(target.hasAttribute(attr), `expected ${attr} to be present on target`).toBe(true); + } + for (const [attr, value] of Object.entries(valueAttrs)) { + expect(target.getAttribute(attr), `expected ${attr}="${value}" on target`).toBe(value); + } + }); + + it('removes forwarded attributes when removed from host', () => { + const el = create(defineVideoElement()); + const target = el.target!; + + el.setAttribute('poster', 'https://example.com/poster.jpg'); + expect(target.getAttribute('poster')).toBe('https://example.com/poster.jpg'); + + el.removeAttribute('poster'); + expect(target.hasAttribute('poster')).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('preload', 'auto'); + expect(target.getAttribute('preload')).toBe('auto'); + }); + }); + + describe('non-MediaHost attribute property accessors', () => { + it('boolean property getter returns false when attribute is absent', () => { + const el = create(defineVideoElement()); + expect(el.autoplay).toBe(false); + expect(el.controls).toBe(false); + expect(el.loop).toBe(false); + }); + + it('boolean property getter returns true when attribute is present', () => { + const el = create(defineVideoElement()); + el.setAttribute('autoplay', ''); + el.setAttribute('controls', ''); + el.setAttribute('loop', ''); + + expect(el.autoplay).toBe(true); + expect(el.controls).toBe(true); + expect(el.loop).toBe(true); + }); + + it('boolean property setter adds the attribute', () => { + const el = create(defineVideoElement()); + el.autoplay = true; + expect(el.hasAttribute('autoplay')).toBe(true); + expect(el.target!.hasAttribute('autoplay')).toBe(true); + }); + + it('boolean property setter removes the attribute when set to false', () => { + const el = create(defineVideoElement()); + el.autoplay = true; + el.autoplay = false; + expect(el.hasAttribute('autoplay')).toBe(false); + expect(el.target!.hasAttribute('autoplay')).toBe(false); + }); + + it('string property getter returns the attribute value', () => { + const el = create(defineVideoElement()); + el.setAttribute('preload', 'auto'); + expect(el.preload).toBe('auto'); + }); + + it('string property getter returns null when attribute is absent', () => { + const el = create(defineVideoElement()); + expect(el.poster).toBeNull(); + expect(el.preload).toBeNull(); + }); + + it('string property setter sets the attribute and forwards to target', () => { + const el = create(defineVideoElement()); + el.poster = 'https://example.com/poster.jpg'; + expect(el.getAttribute('poster')).toBe('https://example.com/poster.jpg'); + expect(el.target!.getAttribute('poster')).toBe('https://example.com/poster.jpg'); + }); + + it('removing attribute resets string property getter to null', () => { + const el = create(defineVideoElement()); + el.poster = 'https://example.com/poster.jpg'; + el.removeAttribute('poster'); + expect(el.poster).toBeNull(); + }); + + it('property accessors work for all non-MediaHost video attributes', () => { + const el = create(defineVideoElement()); + + el.controls = true; + expect(el.controls).toBe(true); + expect(el.hasAttribute('controls')).toBe(true); + + el.loop = true; + expect(el.loop).toBe(true); + expect(el.hasAttribute('loop')).toBe(true); + + el.playsInline = true; + expect(el.playsInline).toBe(true); + expect(el.hasAttribute('playsinline')).toBe(true); + + el.poster = 'poster.jpg'; + expect(el.poster).toBe('poster.jpg'); + expect(el.getAttribute('poster')).toBe('poster.jpg'); + + el.preload = 'metadata'; + expect(el.preload).toBe('metadata'); + expect(el.getAttribute('preload')).toBe('metadata'); + + el.crossOrigin = 'anonymous'; + expect(el.crossOrigin).toBe('anonymous'); + expect(el.getAttribute('crossorigin')).toBe('anonymous'); + + el.loading = 'lazy'; + expect(el.loading).toBe('lazy'); + expect(el.getAttribute('loading')).toBe('lazy'); + }); + + it('property accessors are defined on the prototype, not the constructor', () => { + const { Ctor } = defineVideoElement(); + const proto = Ctor.prototype; + + expect(Object.getOwnPropertyDescriptor(proto, 'autoplay')).toBeDefined(); + expect(Object.getOwnPropertyDescriptor(proto, 'controls')).toBeDefined(); + expect(Object.getOwnPropertyDescriptor(proto, 'loop')).toBeDefined(); + expect(Object.getOwnPropertyDescriptor(proto, 'poster')).toBeDefined(); + expect(Object.getOwnPropertyDescriptor(proto, 'preload')).toBeDefined(); + + expect(Object.getOwnPropertyDescriptor(Ctor, 'autoplay')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(Ctor, 'poster')).toBeUndefined(); + }); + }); + + describe('setter attributes route through MediaHost property', () => { + it('sets muted property via attribute', () => { + const el = create(defineVideoElement()); + el.setAttribute('muted', ''); + expect(el.muted).toBe(true); + }); + + it('unsets muted property when attribute removed', () => { + const el = create(defineVideoElement()); + el.setAttribute('muted', ''); + expect(el.muted).toBe(true); + + el.removeAttribute('muted'); + expect(el.muted).toBe(false); + }); + + it('sets src property via attribute', () => { + const el = create(defineVideoElement()); + el.setAttribute('src', 'https://example.com/video.mp4'); + expect(el.src).toBe('https://example.com/video.mp4'); + }); + + it('sets volume directly on MediaHost', () => { + const el = create(defineVideoElement()); + el.volume = 0.5; + expect(el.volume).toBe(0.5); + }); + }); + + describe('MediaHost property delegation', () => { + it('delegates getter properties to the MediaHost', () => { + const el = create(defineVideoElement()); + expect(el.paused).toBe(true); + expect(el.duration).toBeNaN(); + expect(el.currentTime).toBe(0); + }); + + it('delegates setter properties to the MediaHost', () => { + const el = create(defineVideoElement()); + el.volume = 0.5; + expect(el.target!.volume).toBe(0.5); + }); + + it('delegates methods to the MediaHost', () => { + const el = create(defineVideoElement()); + expect(typeof el.play).toBe('function'); + expect(typeof el.pause).toBe('function'); + expect(typeof el.load).toBe('function'); + }); + + it('excludes attach, detach, and destroy from delegation', () => { + const { Ctor } = defineVideoElement(); + expect(Object.getOwnPropertyDescriptor(Ctor.prototype, 'attach')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(Ctor.prototype, 'detach')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(Ctor.prototype, 'destroy')).toBeUndefined(); + }); + }); + + describe('event delegation', () => { + it('forwards non-composed media events from the target to the host', () => { + const el = create(defineVideoElement()); + const handler = vi.fn(); + + el.addEventListener('play', handler); + el.target!.dispatchEvent(new Event('play')); + + expect(handler).toHaveBeenCalledOnce(); + }); + + it('removes forwarded listener via removeEventListener', () => { + const el = create(defineVideoElement()); + const handler = vi.fn(); + + el.addEventListener('play', handler); + el.removeEventListener('play', handler); + el.target!.dispatchEvent(new Event('play')); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('delivers DOM events dispatched directly on the host element', () => { + const el = create(defineVideoElement()); + const handler = vi.fn(); + + el.addEventListener('click', handler); + el.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(handler).toHaveBeenCalledOnce(); + }); + + it('delivers custom events dispatched on the host element', () => { + const el = create(defineVideoElement()); + const handler = vi.fn(); + + el.addEventListener('my-custom-event', handler); + el.dispatchEvent(new CustomEvent('my-custom-event')); + + expect(handler).toHaveBeenCalledOnce(); + }); + + it('does not double-fire composed events that originate on the target', () => { + const el = create(defineVideoElement()); + const handler = vi.fn(); + + el.addEventListener('click', handler); + el.target!.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + + expect(handler).toHaveBeenCalledOnce(); + }); + }); + + describe('disconnectedCallback', () => { + it('calls destroy on the MediaHost when disconnected', () => { + const el = create(defineVideoElement()); + expect(el.destroyed).toBe(false); + + el.remove(); + expect(el.destroyed).toBe(true); + }); + + it('does not destroy when keep-alive attribute is set', () => { + const el = create(defineVideoElement()); + el.setAttribute('keep-alive', ''); + + el.remove(); + expect(el.destroyed).toBe(false); + }); + }); + + describe('non-Attributes properties are directly delegated', () => { + it('excludes non-Attributes properties from observedAttributes', () => { + const { Ctor } = defineVideoElementWithObjects(); + const observed = Ctor.observedAttributes; + expect(observed).not.toContain('config'); + expect(observed).not.toContain('debug'); + expect(observed).toContain('src'); + }); + + it('allows object properties to be set via JS', () => { + const el = create(defineVideoElementWithObjects()); + const newConfig = { startLevel: 3, maxBufferLength: 60 }; + el.config = newConfig; + expect(el.config).toBe(newConfig); + }); + + it('directly delegates primitive properties not in Attributes', () => { + const el = create(defineVideoElementWithObjects()); + el.debug = true; + expect(el.debug).toBe(true); + expect(el.hasAttribute('debug')).toBe(false); + }); + }); + + describe('property setters set attribute and delegate to MediaHost', () => { + it('string setter sets attribute on the custom element', () => { + const el = create(defineTrackingVideoElement()); + el.src = 'https://example.com/video.mp4'; + expect(el.getAttribute('src')).toBe('https://example.com/video.mp4'); + }); + + it('string setter delegates value to the MediaHost via attributeChangedCallback', () => { + const el = create(defineTrackingVideoElement()); + el.src = 'https://example.com/video.mp4'; + expect(el.src).toBe('https://example.com/video.mp4'); + }); + + it('number setter delegates directly to the MediaHost', () => { + const el = create(defineTrackingVideoElement()); + el.volume = 0.5; + expect(el.volume).toBe(0.5); + }); + + it('number setter does not set attribute for non-Attributes properties', () => { + const el = create(defineTrackingVideoElement()); + el.volume = 0.5; + expect(el.hasAttribute('volume')).toBe(false); + }); + + it('boolean setter toggles attribute on the custom element', () => { + const el = create(defineTrackingVideoElement()); + el.muted = true; + expect(el.hasAttribute('muted')).toBe(true); + }); + + it('boolean setter removes attribute when set to false', () => { + const el = create(defineTrackingVideoElement()); + el.muted = true; + el.muted = false; + expect(el.hasAttribute('muted')).toBe(false); + }); + + it('boolean setter delegates value to the MediaHost via attributeChangedCallback', () => { + const el = create(defineTrackingVideoElement()); + el.muted = true; + expect(el.muted).toBe(true); + + el.muted = false; + expect(el.muted).toBe(false); + }); + + it('currentTime setter delegates directly to MediaHost', () => { + const el = create(defineTrackingVideoElement()); + el.currentTime = 42; + expect(el.currentTime).toBe(42); + expect(el.hasAttribute('current-time')).toBe(false); + }); + + it('playbackRate setter delegates directly to MediaHost', () => { + const el = create(defineTrackingVideoElement()); + el.playbackRate = 2; + expect(el.playbackRate).toBe(2); + expect(el.hasAttribute('playback-rate')).toBe(false); + }); + + it('attribute is set before MediaHost setter is called', () => { + const el = create(defineTrackingVideoElement()); + const spy = vi.spyOn(el, 'setAttribute'); + el.src = 'video.mp4'; + + expect(spy).toHaveBeenCalledWith('src', 'video.mp4'); + expect(spy.mock.invocationCallOrder[0]).toBeLessThan(Number.POSITIVE_INFINITY); + }); + + it('MediaHost setter receives the coerced value for each type', () => { + const el = create(defineTrackingVideoElement()); + + el.src = 'video.mp4'; + el.volume = 0.75; + el.muted = true; + el.currentTime = 10; + el.playbackRate = 1.5; + + expect(el.src).toBe('video.mp4'); + expect(el.volume).toBe(0.75); + expect(el.muted).toBe(true); + expect(el.currentTime).toBe(10); + expect(el.playbackRate).toBe(1.5); + }); + + it('setting the same attribute value does not re-trigger the MediaHost setter', () => { + const el = create(defineTrackingVideoElement()); + const spy = vi.fn(); + const origSetAttribute = el.setAttribute.bind(el); + + el.setAttribute = (...args: [string, string]) => { + origSetAttribute(...args); + spy(...args); + }; + + el.src = 'video.mp4'; + expect(spy).toHaveBeenCalledOnce(); + expect(el.src).toBe('video.mp4'); + }); + + it('defaultMuted getter reflects the muted attribute', () => { + const el = create(defineTrackingVideoElement()); + expect(el.defaultMuted).toBe(false); + + el.setAttribute('muted', ''); + expect(el.defaultMuted).toBe(true); + + el.removeAttribute('muted'); + expect(el.defaultMuted).toBe(false); + }); + + it('defaultMuted setter toggles the muted attribute', () => { + const el = create(defineTrackingVideoElement()); + el.defaultMuted = true; + expect(el.hasAttribute('muted')).toBe(true); + + el.defaultMuted = false; + expect(el.hasAttribute('muted')).toBe(false); + }); + + it('defaultMuted setter triggers the MediaHost muted setter via attributeChangedCallback', () => { + const el = create(defineTrackingVideoElement()); + el.defaultMuted = true; + expect(el.muted).toBe(true); + + el.defaultMuted = false; + expect(el.muted).toBe(false); + }); + + it('muted property setter and defaultMuted share the same attribute', () => { + const el = create(defineTrackingVideoElement()); + el.muted = true; + expect(el.defaultMuted).toBe(true); + + el.defaultMuted = false; + expect(el.muted).toBe(false); + }); + + it('object-typed properties bypass attribute and delegate directly to MediaHost', () => { + const el = create(defineVideoElementWithObjects()); + const config = { startLevel: 2 }; + el.config = config; + + expect(el.hasAttribute('config')).toBe(false); + expect(el.config).toBe(config); + }); + }); + + describe('initial attribute forwarding', () => { + it('applies attributes present at construction time to the template', () => { + const { tag } = defineVideoElement(); + + const container = document.createElement('div'); + document.body.appendChild(container); + container.innerHTML = `<${tag} poster="https://example.com/poster.jpg" crossorigin="anonymous">`; + + const el = container.querySelector(tag)!; + const video = el.shadowRoot!.querySelector('video')!; + + expect(video.getAttribute('poster')).toBe('https://example.com/poster.jpg'); + expect(video.getAttribute('crossorigin')).toBe('anonymous'); + }); + + it('excludes attribute-reflected MediaHost props from the inner element template', () => { + const { tag } = defineVideoElement(); + + const container = document.createElement('div'); + document.body.appendChild(container); + container.innerHTML = `<${tag} src="video.mp4" volume="0.5" current-time="10" playback-rate="2" muted poster="poster.jpg">`; + + const el = container.querySelector(tag)!; + const video = el.shadowRoot!.querySelector('video')!; + + // src and muted are in Attributes AND have MediaHost setters, excluded from template + expect(video.hasAttribute('src')).toBe(false); + expect(video.hasAttribute('muted')).toBe(false); + + // volume, current-time, playback-rate are not in Attributes at all, so not in template + expect(video.hasAttribute('volume')).toBe(false); + expect(video.hasAttribute('current-time')).toBe(false); + expect(video.hasAttribute('playback-rate')).toBe(false); + + // poster is in Attributes but has no MediaHost setter, forwarded to template + expect(video.getAttribute('poster')).toBe('poster.jpg'); + }); + + it('excludes non-allowed attributes from the inner element template', () => { + const { tag } = defineVideoElement(); + + const container = document.createElement('div'); + document.body.appendChild(container); + container.innerHTML = `<${tag} class="player" data-id="123" poster="poster.jpg" autoplay>`; + + const el = container.querySelector(tag)!; + const video = el.shadowRoot!.querySelector('video')!; + + expect(video.hasAttribute('class')).toBe(false); + expect(video.hasAttribute('data-id')).toBe(false); + + expect(video.getAttribute('poster')).toBe('poster.jpg'); + expect(video.hasAttribute('autoplay')).toBe(true); + }); + }); + + describe('subclass properties override', () => { + it('includes subclass-added properties in observedAttributes', () => { + const Base = CustomMediaElement('video', TestVideoHost); + class Extended extends Base { + static properties = { + ...Base.properties, + playbackId: { type: String, attribute: 'playback-id' }, + }; + } + + const tag = `test-video-${++tagCounter}`; + customElements.define(tag, Extended); + + expect(Extended.observedAttributes).toContain('playback-id'); + }); + + it('defines property accessors for subclass-added properties', () => { + const Base = CustomMediaElement('video', TestVideoHost); + class Extended extends Base { + static properties = { + ...Base.properties, + playbackId: { type: String, attribute: 'playback-id' }, + }; + } + + const tag = `test-video-${++tagCounter}`; + customElements.define(tag, Extended); + + const el = new Extended(); + document.body.appendChild(el); + + el.setAttribute('playback-id', 'abc123'); + expect((el as any).playbackId).toBe('abc123'); + }); + + it('subclass property setter sets the attribute', () => { + const Base = CustomMediaElement('video', TestVideoHost); + class Extended extends Base { + static properties = { + ...Base.properties, + playbackId: { type: String, attribute: 'playback-id' }, + }; + } + + const tag = `test-video-${++tagCounter}`; + customElements.define(tag, Extended); + + const el = new Extended(); + document.body.appendChild(el); + + (el as any).playbackId = 'xyz789'; + expect(el.getAttribute('playback-id')).toBe('xyz789'); + }); + }); +}); diff --git a/packages/core/src/dom/media/dash/index.ts b/packages/core/src/dom/media/dash/index.ts index cfb8f02f..dd05f35b 100644 --- a/packages/core/src/dom/media/dash/index.ts +++ b/packages/core/src/dom/media/dash/index.ts @@ -1,47 +1,46 @@ import * as dashjs from 'dashjs'; +import type { MediaEngineHost } from '../../../core/media/types'; +import { HTMLVideoElementHost } from '../video-host'; -import { type Delegate, DelegateMixin } from '../../../core/media/delegate'; -import { CustomVideoElement } from '../custom-media-element'; -import { VideoProxy } from '../proxy'; - -export class DashMediaDelegate implements Delegate { +export class DashMedia + extends HTMLVideoElementHost + implements MediaEngineHost +{ #engine: dashjs.MediaPlayerClass; - #src: string = ''; + #src = ''; constructor() { + super(); this.#engine = dashjs.MediaPlayer().create(); this.#engine.initialize(undefined, undefined, false); } - get engine(): dashjs.MediaPlayerClass { + get engine() { return this.#engine; } - attach(target: EventTarget): void { - this.#engine.attachView(target as HTMLMediaElement); + get src() { + return this.#src; } - detach(): void { - // dash.js types don't reflect null support, but null is valid for detaching - this.#engine.attachView(null as unknown as HTMLMediaElement); - } - - destroy(): void { - this.#engine.destroy(); - } - - set src(src: string) { + set src(src) { this.#src = src; this.#engine.attachSource(src); } - get src(): string { - return this.#src; + attach(target: HTMLVideoElement) { + super.attach(target); + this.#engine.attachView(target); + } + + detach() { + super.detach(); + // dash.js types don't reflect null support, but null is valid for detaching + this.#engine.attachView(null as unknown as HTMLVideoElement); + } + + destroy() { + this.detach(); + this.#engine.destroy(); } } - -// This is used by the web component because it needs to extend HTMLElement! -export class DashCustomMedia extends DelegateMixin(CustomVideoElement, DashMediaDelegate) {} - -// This is used by the React component. -export class DashMedia extends DelegateMixin(VideoProxy, DashMediaDelegate) {} 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 new file mode 100644 index 00000000..19c54f4e --- /dev/null +++ b/packages/core/src/dom/media/dash/tests/dash-media.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('dashjs', () => { + function create() { + return { + initialize: vi.fn(), + attachView: vi.fn(), + attachSource: vi.fn(), + destroy: vi.fn(), + }; + } + return { MediaPlayer: () => ({ create }), default: { MediaPlayer: () => ({ create }) } }; +}); + +import { DashMedia } from '../index'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +function setup() { + const video = document.createElement('video'); + document.body.appendChild(video); + + const media = new DashMedia(); + media.attach(video); + + return { media, video }; +} + +describe('DashMedia', () => { + describe('destroy', () => { + it('removes forwarding listeners from the native element', () => { + const { media, video } = setup(); + + const playHandler = vi.fn(); + media.addEventListener('play', playHandler); + + video.dispatchEvent(new Event('play')); + expect(playHandler).toHaveBeenCalledOnce(); + + media.destroy(); + playHandler.mockClear(); + + video.dispatchEvent(new Event('play')); + expect(playHandler).not.toHaveBeenCalled(); + }); + + it('nullifies the target reference', () => { + const { media } = setup(); + + expect(media.target).not.toBeNull(); + + media.destroy(); + + expect(media.target).toBeNull(); + }); + }); +}); diff --git a/packages/core/src/dom/media/hls/errors.ts b/packages/core/src/dom/media/hls/errors.ts index 30706f73..78730c2f 100644 --- a/packages/core/src/dom/media/hls/errors.ts +++ b/packages/core/src/dom/media/hls/errors.ts @@ -1,13 +1,8 @@ -import type { Constructor } from '@videojs/utils/types'; +import type { Constructor, MixinReturn } from '@videojs/utils/types'; import type { ErrorData } from 'hls.js'; import Hls from 'hls.js'; - import { MediaError } from '../../../core/media/media-error'; - -export interface HlsEngineHost extends EventTarget { - readonly engine: Hls | null; - readonly target: HTMLMediaElement | null; -} +import type { HlsEngineHost } from './types'; const hlsErrorTypeToCode: Record = { [Hls.ErrorTypes.NETWORK_ERROR]: MediaError.MEDIA_ERR_NETWORK, @@ -18,7 +13,7 @@ const hlsErrorTypeToCode: Record = { }; export function HlsJsMediaErrorsMixin>(BaseClass: Base) { - class HlsJsMediaErrors extends (BaseClass as Constructor) { + class HlsJsMediaErrors extends BaseClass { #disconnect: AbortController | null = null; #error: MediaError | null = null; @@ -73,5 +68,5 @@ export function HlsJsMediaErrorsMixin>(B } } - return HlsJsMediaErrors as unknown as Base & Constructor<{ readonly error: MediaError | null }>; + return HlsJsMediaErrors as unknown as MixinReturn; } diff --git a/packages/core/src/dom/media/hls/hlsjs.ts b/packages/core/src/dom/media/hls/hlsjs.ts index fc485e53..c59318ec 100644 --- a/packages/core/src/dom/media/hls/hlsjs.ts +++ b/packages/core/src/dom/media/hls/hlsjs.ts @@ -1,5 +1,6 @@ import Hls, { type HlsConfig } from 'hls.js'; -import type { Delegate } from '../../../core/media/delegate'; +import type { MediaEngineHost } from '../../../core/media/types'; +import { HTMLVideoElementHost } from '../video-host'; import { HlsJsMediaErrorsMixin } from './errors'; import { HlsJsMediaMetadataTracksMixin } from './metadata-tracks'; import { HlsJsMediaPreloadMixin } from './preload'; @@ -14,7 +15,7 @@ export const defaultHlsConfig: Partial = { autoStartLoad: false, }; -class HlsJsMediaDelegateBase extends EventTarget implements Delegate { +class HlsJsMediaBase extends HTMLVideoElementHost implements MediaEngineHost { #engine: Hls | null = null; constructor(params: { config: Partial }) { @@ -25,10 +26,6 @@ class HlsJsMediaDelegateBase extends EventTarget implements Delegate { }); } - get target() { - return this.#engine?.media ?? null; - } - get engine() { return this.#engine; } @@ -41,20 +38,23 @@ class HlsJsMediaDelegateBase extends EventTarget implements Delegate { this.#engine?.loadSource(src); } - attach(target: HTMLMediaElement) { + attach(target: HTMLVideoElement) { + super.attach(target); this.#engine?.attachMedia(target); } detach() { this.#engine?.detachMedia(); + super.detach(); } destroy() { + this.detach(); this.#engine?.destroy(); this.#engine = null; } } -export class HlsJsMediaDelegate extends HlsJsMediaPreloadMixin( - HlsJsMediaMetadataTracksMixin(HlsJsMediaTextTracksMixin(HlsJsMediaErrorsMixin(HlsJsMediaDelegateBase))) +export class HlsJsMedia extends HlsJsMediaPreloadMixin( + HlsJsMediaMetadataTracksMixin(HlsJsMediaTextTracksMixin(HlsJsMediaErrorsMixin(HlsJsMediaBase))) ) {} diff --git a/packages/core/src/dom/media/hls/index.ts b/packages/core/src/dom/media/hls/index.ts index 3271fc07..67a28b9e 100644 --- a/packages/core/src/dom/media/hls/index.ts +++ b/packages/core/src/dom/media/hls/index.ts @@ -1,11 +1,9 @@ import { shallowEqual } from '@videojs/utils/object'; import Hls from 'hls.js'; -import { type Delegate, DelegateMixin } from '../../../core/media/delegate'; import { bridgeEvents } from '../../../core/utils/bridge-events'; -import { CustomVideoElement } from '../custom-media-element'; -import { NativeHlsMediaDelegate } from '../native-hls'; -import { VideoProxy } from '../proxy'; -import { HlsJsMediaDelegate } from './hlsjs'; +import { NativeHlsMedia } from '../native-hls'; +import { HTMLVideoElementHost } from '../video-host'; +import { HlsJsMedia } from './hlsjs'; export type PreloadType = '' | 'none' | 'metadata' | 'auto'; @@ -24,22 +22,17 @@ export const SourceTypes = { MP4: 'video/mp4', }; -export class HlsMediaDelegate extends EventTarget implements Delegate { - #target: HTMLMediaElement | null = null; - #delegate: HlsJsMediaDelegate | NativeHlsMediaDelegate | null = null; - #src: string = ''; +export class HlsMedia extends HTMLVideoElementHost { + #delegate: HlsJsMedia | NativeHlsMedia | null = null; + #src = ''; #type: SourceType | undefined; #preferPlayback: PlaybackType | undefined = 'mse'; #config: Record = {}; - #debug: boolean = false; + #debug = false; #preload: PreloadType = 'metadata'; #loadRequested?: Promise | null; #prevEngineProps?: Record | null; - get target() { - return this.#target; - } - get engine() { return this.#delegate?.engine ?? null; } @@ -58,7 +51,7 @@ export class HlsMediaDelegate extends EventTarget implements Delegate { } /** Explicit source type. When unset, inferred from the source URL extension. */ - get type(): SourceType | undefined { + get type() { return this.#type ?? inferSourceType(this.src); } @@ -68,11 +61,11 @@ export class HlsMediaDelegate extends EventTarget implements Delegate { } /** Whether to prefer `'mse'` (hls.js) or `'native'` (browser-built-in) HLS. */ - get preferPlayback(): PlaybackType | undefined { + get preferPlayback() { return this.#preferPlayback; } - set preferPlayback(value: PlaybackType | undefined) { + set preferPlayback(value) { this.#preferPlayback = value; this.#requestLoad(); } @@ -81,7 +74,7 @@ export class HlsMediaDelegate extends EventTarget implements Delegate { return this.#config; } - set config(config: Record) { + set config(config) { this.#config = config; this.#requestLoad(); } @@ -90,7 +83,7 @@ export class HlsMediaDelegate extends EventTarget implements Delegate { return this.#debug; } - set debug(debug: boolean) { + set debug(debug) { this.#debug = debug; this.#requestLoad(); } @@ -99,26 +92,26 @@ export class HlsMediaDelegate extends EventTarget implements Delegate { return this.#preload; } - set preload(value: PreloadType) { + set preload(value) { this.#preload = value; if (this.#delegate) { this.#delegate.preload = value; } } - attach(target: HTMLMediaElement) { - this.#target = target; + attach(target: HTMLVideoElement) { + super.attach?.(target); this.#delegate?.attach(target); } detach() { - this.#target = null; this.#delegate?.detach(); + super.detach?.(); } destroy() { - this.#engineDestroy(); this.detach(); + this.#engineDestroy(); } load() { @@ -132,8 +125,8 @@ export class HlsMediaDelegate extends EventTarget implements Delegate { Hls.isSupported() && this.type === SourceTypes.M3U8 && this.preferPlayback !== PlaybackTypes.NATIVE; this.#delegate = useMse - ? new HlsJsMediaDelegate({ config: { ...this.config, debug: this.debug } }) - : new NativeHlsMediaDelegate(); + ? new HlsJsMedia({ config: { ...this.config, debug: this.debug } }) + : new NativeHlsMedia(); bridgeEvents(this.#delegate, this); @@ -169,7 +162,7 @@ export class HlsMediaDelegate extends EventTarget implements Delegate { }; } - #engineDestroy(): void { + #engineDestroy() { this.#delegate?.destroy(); this.#delegate = null; this.#prevEngineProps = null; @@ -182,7 +175,3 @@ function inferSourceType(src: string): SourceType { if (path.endsWith('.mp4')) return SourceTypes.MP4; return SourceTypes.M3U8; } - -export class HlsCustomMedia extends DelegateMixin(CustomVideoElement, HlsMediaDelegate) {} - -export class HlsMedia extends DelegateMixin(VideoProxy, HlsMediaDelegate) {} diff --git a/packages/core/src/dom/media/hls/metadata-tracks.ts b/packages/core/src/dom/media/hls/metadata-tracks.ts index f2759a65..3ce59e1d 100644 --- a/packages/core/src/dom/media/hls/metadata-tracks.ts +++ b/packages/core/src/dom/media/hls/metadata-tracks.ts @@ -1,6 +1,6 @@ import type { Constructor } from '@videojs/utils/types'; import Hls from 'hls.js'; -import type { HlsEngineHost } from './preload'; +import type { HlsEngineHost } from './types'; /** * Ensures user-authored metadata and chapters `` elements stay loaded diff --git a/packages/core/src/dom/media/hls/preload.ts b/packages/core/src/dom/media/hls/preload.ts index 8addf5a5..ee3be4b8 100644 --- a/packages/core/src/dom/media/hls/preload.ts +++ b/packages/core/src/dom/media/hls/preload.ts @@ -1,10 +1,6 @@ import type { Constructor } from '@videojs/utils/types'; import Hls from 'hls.js'; - -export interface HlsEngineHost { - readonly engine: Hls | null; - readonly target: HTMLMediaElement | null; -} +import type { HlsEngineHost } from './types'; export type PreloadType = '' | 'none' | 'metadata' | 'auto'; 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 new file mode 100644 index 00000000..0cc3320e --- /dev/null +++ b/packages/core/src/dom/media/hls/tests/hls-media.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MediaError } from '../../../../core/media/media-error'; +import { HlsMedia, SourceTypes } from '../index'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +function fireNativeError(video: HTMLVideoElement, code: number, message = '') { + Object.defineProperty(video, 'error', { + value: { code, message }, + configurable: true, + }); + video.dispatchEvent(new Event('error')); +} + +function setup() { + const video = document.createElement('video'); + document.body.appendChild(video); + + const media = new HlsMedia(); + media.attach(video); + + const handler = vi.fn(); + media.addEventListener('error', handler); + + media.preferPlayback = 'native'; + media.type = SourceTypes.M3U8; + media.load(); + + return { media, video, handler }; +} + +describe('HlsMedia', () => { + describe('error event delegation', () => { + it('dispatches only the enriched error, not the raw native error', () => { + const { video, handler } = setup(); + + fireNativeError(video, MediaError.MEDIA_ERR_NETWORK, 'network failure'); + + expect(handler).toHaveBeenCalledOnce(); + + const event = handler.mock.calls[0]![0] as ErrorEvent; + expect(event).toBeInstanceOf(ErrorEvent); + expect(event.error).toBeInstanceOf(MediaError); + expect(event.error.code).toBe(MediaError.MEDIA_ERR_NETWORK); + }); + + it('exposes enriched error via the error getter', () => { + const { media, video } = setup(); + + expect(media.error).toBeNull(); + + fireNativeError(video, MediaError.MEDIA_ERR_DECODE, 'decode failure'); + + expect(media.error).toBeInstanceOf(MediaError); + expect(media.error!.code).toBe(MediaError.MEDIA_ERR_DECODE); + }); + }); + + describe('event forwarding through delegate', () => { + it('forwards non-error events from native video once', () => { + const { media, video } = setup(); + + const playHandler = vi.fn(); + media.addEventListener('play', playHandler); + + video.dispatchEvent(new Event('play')); + + expect(playHandler).toHaveBeenCalledOnce(); + }); + + it('forwards events added before load', () => { + const video = document.createElement('video'); + document.body.appendChild(video); + + const media = new HlsMedia(); + media.attach(video); + + const pauseHandler = vi.fn(); + media.addEventListener('pause', pauseHandler); + + media.preferPlayback = 'native'; + media.type = SourceTypes.M3U8; + media.load(); + + video.dispatchEvent(new Event('pause')); + + expect(pauseHandler).toHaveBeenCalledOnce(); + }); + }); + + describe('destroy', () => { + it('removes forwarding listeners from the native element', () => { + const { media, video } = setup(); + + const playHandler = vi.fn(); + media.addEventListener('play', playHandler); + + video.dispatchEvent(new Event('play')); + expect(playHandler).toHaveBeenCalledOnce(); + + media.destroy(); + playHandler.mockClear(); + + video.dispatchEvent(new Event('play')); + expect(playHandler).not.toHaveBeenCalled(); + }); + }); + + describe('property proxying', () => { + it('proxies paused from the native element', () => { + const { media } = setup(); + expect(media.paused).toBe(true); + }); + + it('proxies volume to the native element', () => { + const { media, video } = setup(); + media.volume = 0.5; + expect(video.volume).toBe(0.5); + }); + }); +}); 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 ce8b0883..d129c842 100644 --- a/packages/core/src/dom/media/hls/tests/preload.test.ts +++ b/packages/core/src/dom/media/hls/tests/preload.test.ts @@ -1,7 +1,8 @@ import Hls from 'hls.js'; import { describe, expect, it, vi } from 'vitest'; -import { type HlsEngineHost, HlsJsMediaPreloadMixin } from '../preload'; +import { HlsJsMediaPreloadMixin } from '../preload'; +import type { HlsEngineHost } from '../types'; function createEngine(): Hls { const listeners = new Map void>>(); @@ -25,11 +26,12 @@ function createEngine(): Hls { } as unknown as Hls; } -class FakeHost implements HlsEngineHost { +class FakeHost extends EventTarget implements HlsEngineHost { engine: Hls | null; target: HTMLMediaElement | null = null; constructor(engine: Hls | null = null) { + super(); this.engine = engine; } } diff --git a/packages/core/src/dom/media/hls/text-tracks.ts b/packages/core/src/dom/media/hls/text-tracks.ts index 21453d86..bfa088d4 100644 --- a/packages/core/src/dom/media/hls/text-tracks.ts +++ b/packages/core/src/dom/media/hls/text-tracks.ts @@ -2,11 +2,7 @@ import { listen } from '@videojs/utils/dom'; import type { Constructor } from '@videojs/utils/types'; import type { CuesParsedData, NonNativeTextTracksData } from 'hls.js'; import Hls from 'hls.js'; - -interface HlsEngineHost { - readonly engine: Hls | null; - readonly target: HTMLMediaElement | null; -} +import type { HlsEngineHost } from './types'; /** * Bridges hls.js non-native text tracks to native `` elements so the diff --git a/packages/core/src/dom/media/hls/types.ts b/packages/core/src/dom/media/hls/types.ts new file mode 100644 index 00000000..1eef9b13 --- /dev/null +++ b/packages/core/src/dom/media/hls/types.ts @@ -0,0 +1,6 @@ +import type Hls from 'hls.js'; + +export interface HlsEngineHost extends EventTarget { + 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 new file mode 100644 index 00000000..6d4d7115 --- /dev/null +++ b/packages/core/src/dom/media/media-host.ts @@ -0,0 +1,195 @@ +import type { ErrorLike, EventLike, EventTargetLike } from '../../core/media/types'; + +const EMPTY_TIME_RANGES: Readonly = Object.freeze({ + length: 0, + start() { + return 0; + }, + end() { + return 0; + }, +} as TimeRanges); + +export class HTMLMediaElementHost + extends EventTarget + implements EventTargetLike +{ + #target: T | null = null; + #types = new Set(); + + get target() { + return this.#target; + } + + // -- Playback -- + + get paused() { + return this.target?.paused ?? true; + } + + get ended() { + return this.target?.ended ?? false; + } + + play() { + return this.target?.play() ?? Promise.reject(); + } + + pause() { + this.target?.pause(); + } + + // -- Time -- + + get currentTime() { + return this.target?.currentTime ?? 0; + } + + set currentTime(value: number) { + if (this.target) this.target.currentTime = value; + } + + get duration() { + return this.target?.duration ?? NaN; + } + + get seeking() { + return this.target?.seeking ?? false; + } + + // -- Source -- + + get src() { + return this.target?.src ?? ''; + } + + set src(value: string) { + if (this.target) this.target.src = value; + } + + get currentSrc() { + return this.target?.currentSrc ?? ''; + } + + get readyState() { + return this.target?.readyState ?? 0; + } + + load() { + this.target?.load(); + } + + // -- Volume -- + + get volume() { + return this.target?.volume ?? 1; + } + + set volume(value: number) { + if (this.target) this.target.volume = value; + } + + get muted() { + return this.target?.muted ?? false; + } + + set muted(value: boolean) { + if (this.target) this.target.muted = value; + } + + // -- Playback rate -- + + get playbackRate() { + return this.target?.playbackRate ?? 1; + } + + set playbackRate(value: number) { + if (this.target) this.target.playbackRate = value; + } + + // -- Buffer -- + + get buffered(): TimeRanges { + return this.target?.buffered ?? EMPTY_TIME_RANGES; + } + + get seekable(): TimeRanges { + return this.target?.seekable ?? EMPTY_TIME_RANGES; + } + + // -- Error -- + + get error(): ErrorLike | null { + return this.target?.error ?? null; + } + + // -- Text tracks -- + + get textTracks() { + return (this.target?.textTracks as TextTrackList) ?? []; + } + + attach(target: T): void { + if (!target || this.#target === target) return; + this.#target = target; + for (const type of this.#types) { + target.addEventListener(type, this.#forwardEvent); + } + } + + detach(): void { + if (!this.#target) return; + for (const type of this.#types) { + this.#target.removeEventListener(type, this.#forwardEvent); + } + this.#target = null; + } + + querySelectorAll(selectors: string) { + return this.target?.querySelectorAll(selectors) ?? []; + } + + addEventListener( + type: K, + listener: (event: Events[K]) => void, + 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); + this.target?.addEventListener(type, this.#forwardEvent); + } + super.addEventListener(type, listener as EventListener, options); + } + + removeEventListener( + type: K, + listener: (event: Events[K]) => void, + 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); + } + + #forwardEvent = (event: Event) => { + this.dispatchEvent(new (event.constructor as typeof Event)(event.type, event)); + }; +} diff --git a/packages/core/src/dom/media/mux/index.ts b/packages/core/src/dom/media/mux/index.ts index 67cae943..e7e424eb 100644 --- a/packages/core/src/dom/media/mux/index.ts +++ b/packages/core/src/dom/media/mux/index.ts @@ -1,241 +1,13 @@ -import Mux from 'mux-embed'; +import { HlsMedia } from '../hls'; +import { MuxDataMediaMixin } from './mux-data'; -import { DelegateMixin } from '../../../core/media/delegate'; -import { CustomAudioElement, CustomVideoElement } from '../custom-media-element'; -import { Hls, HlsMediaDelegate } from '../hls'; -import { AudioProxy, VideoProxy } from '../proxy'; -import { getPlayerVersion } from './env'; -import type { MuxDataSdk } from './types'; - -const MUX_VIDEO_DOMAIN = 'mux.com'; - -export class MuxMediaDelegate extends HlsMediaDelegate { - static PLAYER_SOFTWARE_NAME = ''; - - #MuxDataSdk: MuxDataSdk | undefined = Mux; - #MuxDataSdkInitializedBefore: boolean = false; - #playbackId: string | null = null; - #customDomain: string = MUX_VIDEO_DOMAIN; - #beaconCollectionDomain: string | undefined; - #disableCookies: boolean = false; - #metadata: Record | undefined; - #envKey: string | undefined; - #playerSoftwareName: string | undefined = (this.constructor as typeof MuxMediaDelegate).PLAYER_SOFTWARE_NAME; - #playerSoftwareVersion: string | undefined = getPlayerVersion(); - #playerInitTime: number | undefined = this.#generatePlayerInitTime(); - - get playbackId() { - return this.#playbackId; - } - - set playbackId(value: string | null) { - if (this.#playbackId === value) return; - this.#playbackId = value; - this.#syncSrc(); - } - - get customDomain(): string { - return this.#customDomain; - } - - set customDomain(value: string) { - const normalized = value || MUX_VIDEO_DOMAIN; - if (this.#customDomain === normalized) return; - this.#customDomain = normalized; - this.#syncSrc(); - } - - get MuxDataSdk() { - return this.#MuxDataSdk; - } - - set MuxDataSdk(value) { - this.#MuxDataSdk = value; - } - - get beaconCollectionDomain(): string | undefined { - return this.#beaconCollectionDomain; - } - - set beaconCollectionDomain(value: string | undefined) { - this.#beaconCollectionDomain = value; - } - - get disableCookies(): boolean { - return this.#disableCookies; - } - - set disableCookies(value: boolean) { - this.#disableCookies = value; - } - - get envKey(): string | undefined { - return this.#envKey; - } - - set envKey(value: string | undefined) { - this.#envKey = value; - } - - get playerSoftwareName(): string | undefined { - return this.#playerSoftwareName; - } - - set playerSoftwareName(value: string | undefined) { - this.#playerSoftwareName = value; - } - - get playerSoftwareVersion(): string | undefined { - return this.#playerSoftwareVersion; - } - - set playerSoftwareVersion(value: string | undefined) { - this.#playerSoftwareVersion = value; - } - - get playerInitTime(): number | undefined { - return this.#playerInitTime; - } - - set playerInitTime(value: number | undefined) { - this.#playerInitTime = value; - } - - get metadata(): Record | undefined { - return this.#metadata; - } - - set metadata(value: Record | undefined) { - this.#metadata = value; - } - - attach(target: HTMLMediaElement): void { - 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(): void { - if (this.target?.mux) { - this.target.mux.destroy(); - delete this.target.mux; - } - super.detach(); - } - - load(): void { - super.load(); - this.#initializeMuxDataSdk(); - } - - #syncSrc(): void { - this.src = this.#playbackId ? toSrc(this.#playbackId, this.#customDomain) : ''; - } - - #initializeMuxDataSdk(): void { - 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); - metadata.view_session_id = view_session_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(): number | undefined { - if (!this.MuxDataSdk) return undefined; - return this.MuxDataSdk.utils.now(); - } -} - -function toSrc(playbackId: string, customDomain: string): string { - return `https://stream.${customDomain}/${playbackId}.m3u8`; -} - -type MuxSrcProps = Pick; - -export function toVideoId(props: MuxSrcProps & Pick): string | undefined { - if (props.metadata?.video_id) return props.metadata.video_id; - if (!isMuxVideoSrc(props)) return props.src; - return toPlaybackIdFromParameterized(props.playbackId) ?? toPlaybackIdFromSrc(props.src) ?? props.src; -} - -function toPlaybackIdFromParameterized(playbackId: MuxMediaDelegate['playbackId']): string | undefined { - if (!playbackId) return undefined; - const [id] = playbackId.split('?'); - return id || undefined; -} - -export function toPlaybackIdFromSrc(src: MuxMediaDelegate['src']): string | undefined { - if (!src || !src.startsWith('https://stream.')) return undefined; - const [playbackId] = new URL(src).pathname.slice(1).split(/\.m3u8|\//); - return playbackId || undefined; -} - -export function isMuxVideoSrc({ playbackId, src, customDomain }: MuxSrcProps): boolean { - if (playbackId) return true; - if (typeof src !== 'string') return false; - const base = window?.location.href; - const hostname = new URL(src, base).hostname.toLocaleLowerCase(); - - return hostname.includes(MUX_VIDEO_DOMAIN) || (!!customDomain && hostname.includes(customDomain.toLocaleLowerCase())); -} - -export class MuxVideoDelegate extends MuxMediaDelegate { +export class MuxVideoMedia extends MuxDataMediaMixin(HlsMedia) { static PLAYER_SOFTWARE_NAME = 'mux-video'; } -export class MuxAudioDelegate extends MuxMediaDelegate { +// TODO: HlsMedia extends HTMLVideoElementHost, we should extend +// HTMLAudioElementHost instead but this would require a HlsMediaMixin, +// keep it simple for now. +export class MuxAudioMedia extends MuxDataMediaMixin(HlsMedia) { static PLAYER_SOFTWARE_NAME = 'mux-audio'; } - -export class MuxCustomMedia extends DelegateMixin(CustomVideoElement, MuxMediaDelegate) {} - -export class MuxMedia extends DelegateMixin(VideoProxy, MuxMediaDelegate) {} - -export class MuxCustomVideo extends DelegateMixin(CustomVideoElement, MuxVideoDelegate) {} - -export class MuxVideo extends DelegateMixin(VideoProxy, MuxVideoDelegate) {} - -export class MuxCustomAudio extends DelegateMixin(CustomAudioElement, MuxAudioDelegate) {} - -export class MuxAudio extends DelegateMixin(AudioProxy, MuxAudioDelegate) {} diff --git a/packages/core/src/dom/media/mux/mux-data.ts b/packages/core/src/dom/media/mux/mux-data.ts new file mode 100644 index 00000000..69157a59 --- /dev/null +++ b/packages/core/src/dom/media/mux/mux-data.ts @@ -0,0 +1,204 @@ +import type { Mixin } from '@videojs/utils/types'; +import Mux from 'mux-embed'; +import type { MediaEngineHost } from '../../../core/media/types'; +import { Hls, type HlsMedia } from '../hls'; +import { getPlayerVersion } from './env'; +import type { MuxDataOptions, MuxDataSdk } from './types'; + +const MUX_VIDEO_DOMAIN = 'mux.com'; + +export interface MuxDataMediaHost extends MediaEngineHost { + readonly debug: boolean; + attach(target: HTMLMediaElement): void; + detach(): void; + load(): void; +} + +export interface MuxDataMediaProps { + MuxDataSdk: MuxDataSdk | undefined; + beaconCollectionDomain: string | undefined; + disableCookies: boolean; + envKey: string | undefined; + playerSoftwareName: string | undefined; + playerSoftwareVersion: string | undefined; + playerInitTime: number | undefined; + metadata: MuxDataOptions['data'] | undefined; +} + +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(); + + get MuxDataSdk() { + return this.#MuxDataSdk; + } + + set MuxDataSdk(value) { + this.#MuxDataSdk = value; + } + + 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(); + } + } + + return MuxDataMedia as any; +}; + +export type MuxVideoIdProps = { + src: string; + metadata?: Record; +}; + +export function toVideoId(props: MuxVideoIdProps): string | undefined { + if (props.metadata?.video_id) return props.metadata.video_id; + if (!isMuxVideoSrc(props)) return props.src; + return toPlaybackIdFromSrc(props.src) ?? props.src; +} + +export function toPlaybackIdFromSrc(src: string): string | undefined { + if (!src || !src.startsWith('https://stream.')) return undefined; + const [playbackId] = new URL(src).pathname.slice(1).split(/\.m3u8|\//); + return playbackId || undefined; +} + +export function isMuxVideoSrc({ src }: MuxVideoIdProps): boolean { + if (typeof src !== 'string') return false; + const base = window?.location.href; + const hostname = new URL(src, base).hostname.toLocaleLowerCase(); + return hostname.includes(MUX_VIDEO_DOMAIN); +} 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 d3ba1e74..41b56ccd 100644 --- a/packages/core/src/dom/media/mux/tests/index.test.ts +++ b/packages/core/src/dom/media/mux/tests/index.test.ts @@ -1,76 +1,22 @@ import { describe, expect, it } from 'vitest'; -import { MuxMediaDelegate } from '..'; +import { MuxVideoMedia } from '..'; -describe('MuxMediaDelegate', () => { - it('defaults playbackId to null', () => { - const delegate = new MuxMediaDelegate(); - expect(delegate.playbackId).toBeNull(); +describe('MuxVideoMedia', () => { + it('accepts src directly', () => { + const media = new MuxVideoMedia(); + media.src = 'https://stream.mux.com/abc123.m3u8'; + + expect(media.src).toBe('https://stream.mux.com/abc123.m3u8'); }); - it('defaults customDomain to mux.com', () => { - const delegate = new MuxMediaDelegate(); - expect(delegate.customDomain).toBe('mux.com'); + it('accepts non-Mux src', () => { + const media = new MuxVideoMedia(); + media.src = 'https://example.com/video.m3u8'; + + expect(media.src).toBe('https://example.com/video.m3u8'); }); - it('sets src when playbackId is set', () => { - const delegate = new MuxMediaDelegate(); - delegate.playbackId = 'abc123'; - - expect(delegate.src).toBe('https://stream.mux.com/abc123.m3u8'); - }); - - it('uses customDomain in the generated src', () => { - const delegate = new MuxMediaDelegate(); - delegate.customDomain = 'example.com'; - delegate.playbackId = 'abc123'; - - expect(delegate.src).toBe('https://stream.example.com/abc123.m3u8'); - }); - - it('updates src when customDomain changes after playbackId', () => { - const delegate = new MuxMediaDelegate(); - delegate.playbackId = 'abc123'; - expect(delegate.src).toBe('https://stream.mux.com/abc123.m3u8'); - - delegate.customDomain = 'custom.tv'; - expect(delegate.src).toBe('https://stream.custom.tv/abc123.m3u8'); - }); - - it('falls back to default domain when customDomain is set to empty', () => { - const delegate = new MuxMediaDelegate(); - delegate.customDomain = 'custom.tv'; - delegate.playbackId = 'abc123'; - expect(delegate.src).toBe('https://stream.custom.tv/abc123.m3u8'); - - delegate.customDomain = ''; - expect(delegate.src).toBe('https://stream.mux.com/abc123.m3u8'); - }); - - it('does not update src when playbackId is set to the same value', () => { - const delegate = new MuxMediaDelegate(); - delegate.playbackId = 'abc123'; - expect(delegate.src).toBe('https://stream.mux.com/abc123.m3u8'); - - delegate.src = 'https://override.example.com/video.m3u8'; - delegate.playbackId = 'abc123'; - expect(delegate.src).toBe('https://override.example.com/video.m3u8'); - }); - - it('does not trigger syncSrc when customDomain is set to empty while already default', () => { - const delegate = new MuxMediaDelegate(); - delegate.playbackId = 'abc123'; - expect(delegate.src).toBe('https://stream.mux.com/abc123.m3u8'); - - delegate.src = 'https://override.example.com/video.m3u8'; - delegate.customDomain = ''; - expect(delegate.src).toBe('https://override.example.com/video.m3u8'); - }); - - it('clears src when playbackId is null and customDomain changes', () => { - const delegate = new MuxMediaDelegate(); - delegate.src = 'https://manual.example.com/video.m3u8'; - delegate.customDomain = 'custom.tv'; - - expect(delegate.src).toBe(''); + it('defaults PLAYER_SOFTWARE_NAME to mux-video', () => { + expect(MuxVideoMedia.PLAYER_SOFTWARE_NAME).toBe('mux-video'); }); }); diff --git a/packages/core/src/dom/media/mux/types.ts b/packages/core/src/dom/media/mux/types.ts index d19dd5b0..c161017f 100644 --- a/packages/core/src/dom/media/mux/types.ts +++ b/packages/core/src/dom/media/mux/types.ts @@ -1,2 +1,2 @@ /// -export type { Mux as MuxDataSdk } from 'mux-embed'; +export type { Mux as MuxDataSdk, Options as MuxDataOptions } from 'mux-embed'; diff --git a/packages/core/src/dom/media/native-hls/errors.ts b/packages/core/src/dom/media/native-hls/errors.ts index 523702a8..49139085 100644 --- a/packages/core/src/dom/media/native-hls/errors.ts +++ b/packages/core/src/dom/media/native-hls/errors.ts @@ -3,10 +3,10 @@ import type { Constructor } from '@videojs/utils/types'; import { MediaError } from '../../../core/media/media-error'; export interface NativeMediaHost extends EventTarget { - readonly target: HTMLMediaElement | null; - attach(target: HTMLMediaElement): void; - detach(): void; - destroy(): void; + readonly target: EventTarget | null; + attach?(target: EventTarget): void; + detach?(): void; + destroy?(): void; } export function NativeHlsMediaErrorsMixin>(BaseClass: Base) { @@ -18,19 +18,19 @@ export function NativeHlsMediaErrorsMixin { expect(host.error).toBeNull(); }); + it('has target set when error handler fires during attach', () => { + const host = new NativeHlsMediaErrors(); + const video = document.createElement('video'); + + let targetDuringError: EventTarget | null = 'unset' as any; + host.addEventListener('error', () => { + targetDuringError = host.target; + }); + + Object.defineProperty(video, 'error', { + value: { code: MediaError.MEDIA_ERR_NETWORK, message: 'fail' }, + configurable: true, + }); + + host.attach(video); + video.dispatchEvent(new Event('error')); + + expect(targetDuringError).toBe(video); + }); + + it('does not register listeners when base attach guard rejects same target', () => { + const host = new NativeHlsMediaErrors(); + const video = document.createElement('video'); + host.attach(video); + + fireNativeError(video, MediaError.MEDIA_ERR_NETWORK, 'first'); + expect(host.error).not.toBeNull(); + + video.dispatchEvent(new Event('emptied')); + expect(host.error).toBeNull(); + + host.attach(video); + + const handler = vi.fn(); + host.addEventListener('error', handler); + fireNativeError(video, MediaError.MEDIA_ERR_DECODE, 'second'); + + expect(handler).toHaveBeenCalledOnce(); + }); + it('re-initializes on re-attach', () => { const { host } = setup(); diff --git a/packages/core/src/dom/media/native-hls/tests/native-hls-custom-media.test.ts b/packages/core/src/dom/media/native-hls/tests/native-hls-custom-media.test.ts index 365bef14..a38f9c07 100644 --- a/packages/core/src/dom/media/native-hls/tests/native-hls-custom-media.test.ts +++ b/packages/core/src/dom/media/native-hls/tests/native-hls-custom-media.test.ts @@ -1,40 +1,27 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { MediaError } from '../../../../core/media/media-error'; -import { NativeHlsCustomMedia } from '../index'; - -let counter = 0; - -function defineElement(): string { - const tag = `test-nhls-cm-${counter++}`; - customElements.define(tag, class extends (NativeHlsCustomMedia as unknown as typeof HTMLElement) {}); - return tag; -} - -function fireNativeError(video: HTMLVideoElement, code: number, message = '') { - Object.defineProperty(video, 'error', { - value: { code, message }, - configurable: true, - }); - video.dispatchEvent(new Event('error')); -} +import { NativeHlsMedia } from '../index'; afterEach(() => { document.body.innerHTML = ''; }); -describe('NativeHlsCustomMedia', () => { +describe('NativeHlsMedia', () => { it('dispatches only the enriched ErrorEvent when a native error fires', () => { - const tag = defineElement(); - const el = document.createElement(tag); - document.body.appendChild(el); + const video = document.createElement('video'); + document.body.appendChild(video); - const video = el.shadowRoot!.querySelector('video')! as HTMLVideoElement; - (el as any).attach(video); + const media = new NativeHlsMedia(); + media.attach(video); const handler = vi.fn(); - el.addEventListener('error', handler); + media.addEventListener('error', handler); - fireNativeError(video, MediaError.MEDIA_ERR_NETWORK, 'network failure'); + Object.defineProperty(video, 'error', { + value: { code: MediaError.MEDIA_ERR_NETWORK, message: 'network failure' }, + configurable: true, + }); + video.dispatchEvent(new Event('error')); expect(handler).toHaveBeenCalledOnce(); diff --git a/packages/core/src/dom/media/predicate.ts b/packages/core/src/dom/media/predicate.ts index 5cb0b0bf..b23bcae9 100644 --- a/packages/core/src/dom/media/predicate.ts +++ b/packages/core/src/dom/media/predicate.ts @@ -1,3 +1,66 @@ -export function hasMetadata(media: HTMLMediaElement): boolean { - return media.readyState >= HTMLMediaElement.HAVE_METADATA; +import { isFunction, isObject } from '@videojs/utils/predicate'; + +import type { + MediaBufferCapability, + MediaErrorCapability, + MediaPauseCapability, + MediaPlaybackRateCapability, + MediaSeekCapability, + MediaSourceCapability, + MediaTextTrackCapability, + MediaVolumeCapability, +} from '../../core/media/types'; + +export function hasMetadata(media: MediaSourceCapability): boolean { + return media.readyState >= 1; +} + +export function isMediaPauseCapable(value: unknown): value is MediaPauseCapability { + return ( + isObject(value) && 'paused' in value && 'ended' in value && isFunction((value as Record).pause) + ); +} + +export function isMediaSeekCapable(value: unknown): value is MediaSeekCapability { + return isObject(value) && 'currentTime' in value && 'duration' in value && 'seeking' in value; +} + +export function isMediaSourceCapable(value: unknown): value is MediaSourceCapability { + return ( + isObject(value) && + 'src' in value && + 'currentSrc' in value && + 'readyState' in value && + isFunction((value as Record).load) + ); +} + +export function isMediaVolumeCapable(value: unknown): value is MediaVolumeCapability { + return isObject(value) && 'volume' in value && 'muted' in value; +} + +export function isMediaPlaybackRateCapable(value: unknown): value is MediaPlaybackRateCapability { + return isObject(value) && 'playbackRate' in value; +} + +export function isMediaBufferCapable(value: unknown): value is MediaBufferCapability { + return isObject(value) && 'buffered' in value && 'seekable' in value; +} + +export function isMediaErrorCapable(value: unknown): value is MediaErrorCapability { + return isObject(value) && 'error' in value; +} + +export function isMediaTextTrackCapable(value: unknown): value is MediaTextTrackCapability { + return isObject(value) && 'textTracks' in value; +} + +export function isQuerySelectorAllCapable( + value: unknown +): value is { + querySelectorAll: (selectors: T) => NodeListOf]>; +} { + return ( + isObject(value) && 'querySelectorAll' in value && isFunction((value as Record).querySelectorAll) + ); } diff --git a/packages/core/src/dom/media/proxy.ts b/packages/core/src/dom/media/proxy.ts deleted file mode 100644 index 44053767..00000000 --- a/packages/core/src/dom/media/proxy.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ProxyMixin } from '../../core/media/proxy'; - -export const VideoProxy = ProxyMixin(globalThis.HTMLVideoElement ?? class {}); - -export const AudioProxy = ProxyMixin(globalThis.HTMLAudioElement ?? class {}); diff --git a/packages/core/src/dom/media/simple-hls/index.ts b/packages/core/src/dom/media/simple-hls/index.ts index 6cc75944..4f69276f 100644 --- a/packages/core/src/dom/media/simple-hls/index.ts +++ b/packages/core/src/dom/media/simple-hls/index.ts @@ -1,10 +1,4 @@ -import { SpfMedia as SpfMediaDelegate } from '@videojs/spf/dom'; -import { DelegateMixin } from '../../../core/media/delegate'; -import { CustomVideoElement } from '../custom-media-element'; -import { VideoProxy } from '../proxy'; +import { SpfMediaMixin } from '@videojs/spf/dom'; +import { HTMLVideoElementHost } from '../video-host'; -// This is used by the web component because it needs to extend HTMLElement! -export class SimpleHlsCustomMedia extends DelegateMixin(CustomVideoElement, SpfMediaDelegate) {} - -// This is used by the React component. -export class SimpleHlsMedia extends DelegateMixin(VideoProxy, SpfMediaDelegate) {} +export class SimpleHlsMedia extends SpfMediaMixin(HTMLVideoElementHost) {} diff --git a/packages/core/src/dom/media/types.ts b/packages/core/src/dom/media/types.ts index ef08effe..e884cf5f 100644 --- a/packages/core/src/dom/media/types.ts +++ b/packages/core/src/dom/media/types.ts @@ -12,17 +12,9 @@ import type { MediaTimeState, MediaVolumeState, } from '../../core/media/state'; +import type { Media } from '../../core/media/types'; -type WithOptional = Required & Partial>; - -export type MediaBaseApi = { - play: () => Promise; - paused: boolean; -}; - -export type MediaApi = WithOptional; - -export type Media = HTMLMediaElement | HTMLAudioElement | HTMLVideoElement; +export type { Media }; export interface MediaContainer extends HTMLElement {} @@ -31,7 +23,7 @@ export interface PlayerTarget { container: MediaContainer | null; } -export type { MediaFeatureAvailability } from '../../core/media/state'; +export type { MediaFeatureAvailability } from '../../core/media/types'; export type PlayerFeature = Slice; diff --git a/packages/core/src/dom/media/video-host.ts b/packages/core/src/dom/media/video-host.ts new file mode 100644 index 00000000..3eb4b469 --- /dev/null +++ b/packages/core/src/dom/media/video-host.ts @@ -0,0 +1,33 @@ +import type { Video, VideoEvents } from '../../core/media/types'; +import type { WebKitPresentationMode, WebKitVideoElement } from '../presentation/types'; +import { HTMLMediaElementHost } from './media-host'; + +export class HTMLVideoElementHost extends HTMLMediaElementHost implements Video { + requestPictureInPicture() { + return this.target?.requestPictureInPicture() ?? Promise.reject(); + } + + requestFullscreen() { + return this.target?.requestFullscreen() ?? Promise.reject(); + } + + webkitEnterFullscreen() { + return (this.target as unknown as WebKitVideoElement | null)?.webkitEnterFullscreen?.(); + } + + webkitExitFullscreen() { + return (this.target as unknown as WebKitVideoElement | null)?.webkitExitFullscreen?.(); + } + + webkitSetPresentationMode(mode: WebKitPresentationMode) { + return (this.target as unknown as WebKitVideoElement | null)?.webkitSetPresentationMode?.(mode); + } + + get webkitDisplayingFullscreen() { + return (this.target as unknown as WebKitVideoElement | null)?.webkitDisplayingFullscreen ?? false; + } + + get webkitPresentationMode() { + return (this.target as unknown as WebKitVideoElement | null)?.webkitPresentationMode ?? 'inline'; + } +} diff --git a/packages/core/src/dom/presentation/fullscreen.ts b/packages/core/src/dom/presentation/fullscreen.ts index 37d1641c..f75f01fa 100644 --- a/packages/core/src/dom/presentation/fullscreen.ts +++ b/packages/core/src/dom/presentation/fullscreen.ts @@ -2,65 +2,48 @@ import { isFunction } from '@videojs/utils/predicate'; import type { WebKitDocument, WebKitFullscreenElement, WebKitVideoElement } from './types'; -/** Check if the Fullscreen API is supported on this platform. */ export function isFullscreenEnabled(): boolean { const doc = document as WebKitDocument; - // Standard API or WebKit prefix if (doc.fullscreenEnabled || doc.webkitFullscreenEnabled) { return true; } - // iOS Safari: check for webkitEnterFullscreen on a test video. - // Unlike webkitSupportsFullscreen, this is available on detached elements. const video = document.createElement('video') as WebKitVideoElement; return isFunction(video.webkitEnterFullscreen); } -/** Get the current fullscreen element from the document. */ export function getFullscreenElement(): Element | null { const doc = document as WebKitDocument; return doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null; } -/** - * Check if a specific element (or its media) is currently in fullscreen. - * - * Uses `:fullscreen` pseudo-class which works across Shadow DOM boundaries. - */ -export function isFullscreenElement(container: HTMLElement | null, media: HTMLMediaElement): boolean { - const video = media as WebKitVideoElement; - - // iOS Safari video-only fullscreen - if (video.webkitDisplayingFullscreen && video.webkitPresentationMode === 'fullscreen') { - return true; +export function isFullscreenElement(container: HTMLElement | null, media: EventTarget): boolean { + if (media instanceof HTMLMediaElement) { + const video = media as WebKitVideoElement; + if (video.webkitDisplayingFullscreen && video.webkitPresentationMode === 'fullscreen') { + return true; + } } const target = container ?? media; - // Direct match with fullscreen element if (getFullscreenElement() === target) return true; - // Use :fullscreen pseudo-class (works in Shadow DOM) - try { - return target.matches(':fullscreen'); - } catch { - return false; + if (target instanceof Element) { + try { + return target.matches(':fullscreen'); + } catch { + return false; + } } + + return false; } -/** - * Request fullscreen mode. - * - * Tries container first (to show custom UI), falls back to media element - * for platforms that only support video fullscreen (iOS Safari). - */ -export async function requestFullscreen(container: HTMLElement | null, media: HTMLMediaElement): Promise { +export async function requestFullscreen(container: HTMLElement | null, media: EventTarget): Promise { const doc = document as WebKitDocument; - const video = media as WebKitVideoElement; - // Try container first, but only if the platform supports element-level fullscreen. - // iOS Safari has requestFullscreen on Element.prototype but it silently fails. if (container && (doc.fullscreenEnabled || doc.webkitFullscreenEnabled)) { const el = container as WebKitFullscreenElement; @@ -73,26 +56,25 @@ export async function requestFullscreen(container: HTMLElement | null, media: HT } } - // Fall back to media element (iOS Safari) - if (isFunction(video.webkitEnterFullscreen)) { - video.webkitEnterFullscreen(); - return; - } + if (media instanceof HTMLMediaElement) { + const video = media as WebKitVideoElement; + if (isFunction(video.webkitEnterFullscreen)) { + video.webkitEnterFullscreen(); + return; + } - // Last resort: try media element with standard API - if (isFunction(media.requestFullscreen)) { - return media.requestFullscreen(); + if (isFunction(media.requestFullscreen)) { + return media.requestFullscreen(); + } } throw new DOMException('Fullscreen not supported', 'NotSupportedError'); } -/** Exit fullscreen mode. */ -export async function exitFullscreen(media?: HTMLMediaElement): Promise { +export async function exitFullscreen(media?: EventTarget): Promise { const doc = document as WebKitDocument; - // iOS Safari: use video element WebKit API first when it's actively in fullscreen. - if (media) { + if (media instanceof HTMLMediaElement) { const video = media as WebKitVideoElement; if (isFunction(video.webkitExitFullscreen) && video.webkitDisplayingFullscreen) { video.webkitExitFullscreen(); @@ -100,15 +82,11 @@ export async function exitFullscreen(media?: HTMLMediaElement): Promise { } } - // Standard API if (isFunction(doc.exitFullscreen)) { return doc.exitFullscreen(); } - // WebKit document API (desktop Safari) if (isFunction(doc.webkitExitFullscreen)) { return doc.webkitExitFullscreen(); } - - // No-op if not in fullscreen (matches browser behavior) } diff --git a/packages/core/src/dom/presentation/pip.ts b/packages/core/src/dom/presentation/pip.ts index f47f5ac6..d97e4daa 100644 --- a/packages/core/src/dom/presentation/pip.ts +++ b/packages/core/src/dom/presentation/pip.ts @@ -2,68 +2,51 @@ import { isFunction } from '@videojs/utils/predicate'; import type { WebKitVideoElement } from './types'; -type MediaWithTarget = HTMLMediaElement & { - target?: unknown; -}; - -function resolveMediaTarget(media: HTMLMediaElement): HTMLMediaElement { - const target = (media as MediaWithTarget).target; +function resolveMediaTarget(media: EventTarget): EventTarget { + const target = (media as EventTarget & { target?: unknown }).target; return target instanceof HTMLMediaElement ? target : media; } -/** - * Check if Picture-in-Picture is supported on this platform. - * - * Note: Safari PWAs don't support PiP even though the API exists. - */ export function isPictureInPictureEnabled(): boolean { - // Check standard PiP API if (document.pictureInPictureEnabled) { - // Safari PWAs have the API but it doesn't work const isSafari = /.*Version\/.*Safari\/.*/.test(navigator.userAgent); const isPWA = typeof matchMedia === 'function' && matchMedia('(display-mode: standalone)').matches; return !isSafari || !isPWA; } - // Check iOS Safari WebKit presentation mode const video = document.createElement('video') as WebKitVideoElement; return isFunction(video.webkitSetPresentationMode); } -/** - * Check if Picture-in-Picture is currently active for a media element. - */ -export function isPictureInPictureElement(media: HTMLMediaElement): boolean { +export function isPictureInPictureElement(media: EventTarget): boolean { const target = resolveMediaTarget(media); - // Standard PiP API if (document.pictureInPictureElement === target) { return true; } - // iOS Safari WebKit presentation mode - const video = target as WebKitVideoElement; - return video.webkitPresentationMode === 'picture-in-picture'; + if (target instanceof HTMLVideoElement) { + const video = target as WebKitVideoElement; + return video.webkitPresentationMode === 'picture-in-picture'; + } + + return false; } -/** - * Request Picture-in-Picture mode. - * - * Uses standard API where available, falls back to iOS Safari's - * WebKit presentation mode. - */ -export async function requestPictureInPicture(media: HTMLMediaElement): Promise { +export async function requestPictureInPicture(media: EventTarget): Promise { const target = resolveMediaTarget(media); + + if (!(target instanceof HTMLVideoElement)) { + throw new DOMException('Picture-in-Picture not supported', 'NotSupportedError'); + } + const video = target as HTMLVideoElement & WebKitVideoElement; - // iOS Safari: use WebKit presentation mode directly. - // The standard API may exist on the prototype but silently fail. if (isFunction(video.webkitSetPresentationMode)) { video.webkitSetPresentationMode('picture-in-picture'); return; } - // Standard PiP API (only available on HTMLVideoElement) if (isFunction(video.requestPictureInPicture)) { await video.requestPictureInPicture(); return; @@ -72,27 +55,19 @@ export async function requestPictureInPicture(media: HTMLMediaElement): Promise< throw new DOMException('Picture-in-Picture not supported', 'NotSupportedError'); } -/** - * Exit Picture-in-Picture mode. - * - * Uses standard API where available, falls back to iOS Safari's - * WebKit presentation mode. - */ -export async function exitPictureInPicture(media?: HTMLMediaElement): Promise { - // iOS Safari: use WebKit presentation mode directly when active. +export async function exitPictureInPicture(media?: EventTarget): Promise { if (media) { const target = resolveMediaTarget(media); - const video = target as WebKitVideoElement; - if (isFunction(video.webkitSetPresentationMode) && video.webkitPresentationMode === 'picture-in-picture') { - video.webkitSetPresentationMode('inline'); - return; + if (target instanceof HTMLVideoElement) { + const video = target as WebKitVideoElement; + if (isFunction(video.webkitSetPresentationMode) && video.webkitPresentationMode === 'picture-in-picture') { + video.webkitSetPresentationMode('inline'); + return; + } } } - // Standard PiP API if (isFunction(document.exitPictureInPicture)) { return document.exitPictureInPicture(); } - - // No-op if not in PiP (matches browser behavior) } diff --git a/packages/core/src/dom/store/features/buffer.ts b/packages/core/src/dom/store/features/buffer.ts index 25294ce7..484c6113 100644 --- a/packages/core/src/dom/store/features/buffer.ts +++ b/packages/core/src/dom/store/features/buffer.ts @@ -2,6 +2,7 @@ import { listen, serializeTimeRanges } from '@videojs/utils/dom'; import type { MediaBufferState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; +import { isMediaBufferCapable } from '../../media/predicate'; export const bufferFeature = definePlayerFeature({ name: 'buffer', @@ -13,6 +14,8 @@ export const bufferFeature = definePlayerFeature({ attach({ target, signal, set }) { const { media } = target; + if (!isMediaBufferCapable(media)) return; + const sync = () => set({ buffered: serializeTimeRanges(media.buffered), diff --git a/packages/core/src/dom/store/features/controls.ts b/packages/core/src/dom/store/features/controls.ts index 838d4d65..fc1481b1 100644 --- a/packages/core/src/dom/store/features/controls.ts +++ b/packages/core/src/dom/store/features/controls.ts @@ -4,6 +4,7 @@ import { isNull } from '@videojs/utils/predicate'; import type { MediaControlsState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; import { findGestureCoordinator } from '../../gesture/coordinator'; +import { isMediaPauseCapable } from '../../media/predicate'; const IDLE_DELAY = 2000; const TAP_THRESHOLD = 250; @@ -24,16 +25,16 @@ export const controlsFeature = definePlayerFeature({ attach({ target, signal, get, set }) { const { media, container } = target; - if (isNull(container)) { - if (__DEV__) { + if (!isMediaPauseCapable(media) || isNull(container)) { + if (__DEV__ && isNull(container)) { console.warn('[vjs] controlsFeature requires a container element for activity tracking.'); } return; } - function computeVisible(userActive: boolean): boolean { + const computeVisible = (userActive: boolean): boolean => { return userActive || media.paused; - } + }; // Idle timer let idleTimer: ReturnType | undefined; @@ -104,7 +105,7 @@ export const controlsFeature = definePlayerFeature({ } // Recompute visibility when playback state changes. - function onPlaybackChange() { + const onPlaybackChange = () => { const { userActive } = get(); set({ controlsVisible: computeVisible(userActive) }); @@ -112,7 +113,7 @@ export const controlsFeature = definePlayerFeature({ if (!media.paused && userActive) { scheduleIdle(); } - } + }; // Container event listeners listen(container, 'pointermove', setActive, { signal }); diff --git a/packages/core/src/dom/store/features/error.ts b/packages/core/src/dom/store/features/error.ts index 18841549..ab1bb7ae 100644 --- a/packages/core/src/dom/store/features/error.ts +++ b/packages/core/src/dom/store/features/error.ts @@ -2,6 +2,7 @@ import { listen } from '@videojs/utils/dom'; import type { MediaErrorState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; +import { isMediaErrorCapable } from '../../media/predicate'; export const errorFeature = definePlayerFeature({ name: 'error', @@ -15,6 +16,8 @@ export const errorFeature = definePlayerFeature({ attach({ target, signal, set }) { const { media } = target; + if (!isMediaErrorCapable(media)) return; + const syncError = () => set({ error: media.error }); listen(media, 'error', syncError, { signal }); diff --git a/packages/core/src/dom/store/features/playback-rate.ts b/packages/core/src/dom/store/features/playback-rate.ts index 164ef9d1..8e12caed 100644 --- a/packages/core/src/dom/store/features/playback-rate.ts +++ b/packages/core/src/dom/store/features/playback-rate.ts @@ -2,6 +2,7 @@ import { listen } from '@videojs/utils/dom'; import type { MediaPlaybackRateState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; +import { isMediaPlaybackRateCapable } from '../../media/predicate'; const DEFAULT_RATES: readonly number[] = [0.2, 0.5, 0.7, 1, 1.2, 1.5, 1.7, 2]; @@ -11,13 +12,16 @@ export const playbackRateFeature = definePlayerFeature({ playbackRates: DEFAULT_RATES, playbackRate: 1, setPlaybackRate(rate: number) { - target().media.playbackRate = rate; + const { media } = target(); + if (isMediaPlaybackRateCapable(media)) media.playbackRate = rate; }, }), attach({ target, signal, set }) { const { media } = target; + if (!isMediaPlaybackRateCapable(media)) return; + const sync = () => set({ playbackRate: media.playbackRate }); sync(); diff --git a/packages/core/src/dom/store/features/playback.ts b/packages/core/src/dom/store/features/playback.ts index 111de8ae..ecb22ba9 100644 --- a/packages/core/src/dom/store/features/playback.ts +++ b/packages/core/src/dom/store/features/playback.ts @@ -2,6 +2,7 @@ import { listen } from '@videojs/utils/dom'; import type { MediaPlaybackState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; +import { isMediaPauseCapable, isMediaSeekCapable, isMediaSourceCapable } from '../../media/predicate'; export const playbackFeature = definePlayerFeature({ name: 'playback', @@ -14,10 +15,12 @@ export const playbackFeature = definePlayerFeature({ return target().media.play(); }, pause() { - target().media.pause(); + const { media } = target(); + if (isMediaPauseCapable(media)) media.pause(); }, togglePaused() { const media = target().media; + if (!isMediaPauseCapable(media)) return false; if (media.paused) { media.play(); return true; @@ -30,6 +33,8 @@ export const playbackFeature = definePlayerFeature({ attach({ target, signal, set }) { const { media } = target; + if (!isMediaPauseCapable(media) || !isMediaSeekCapable(media) || !isMediaSourceCapable(media)) return; + const sync = () => set({ paused: media.paused, diff --git a/packages/core/src/dom/store/features/source.ts b/packages/core/src/dom/store/features/source.ts index a598e0b7..b911110a 100644 --- a/packages/core/src/dom/store/features/source.ts +++ b/packages/core/src/dom/store/features/source.ts @@ -2,6 +2,7 @@ import { listen } from '@videojs/utils/dom'; import type { MediaSourceState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; +import { isMediaSourceCapable } from '../../media/predicate'; export const sourceFeature = definePlayerFeature({ name: 'source', @@ -9,9 +10,10 @@ export const sourceFeature = definePlayerFeature({ source: null, canPlay: false, loadSource(src: string) { - signals.clear(); // Cancel pending operations (e.g., seek) + signals.clear(); const { media } = target(); + if (!isMediaSourceCapable(media)) return src; media.src = src; media.load(); @@ -22,6 +24,8 @@ export const sourceFeature = definePlayerFeature({ attach({ target, signal, set }) { const { media } = target; + if (!isMediaSourceCapable(media)) return; + const sync = () => set({ source: media.currentSrc || media.src || null, diff --git a/packages/core/src/dom/store/features/text-track.ts b/packages/core/src/dom/store/features/text-track.ts index 45d3b714..de713d2f 100644 --- a/packages/core/src/dom/store/features/text-track.ts +++ b/packages/core/src/dom/store/features/text-track.ts @@ -1,7 +1,9 @@ import { findTrackElement, getTextTrackList, listen } from '@videojs/utils/dom'; import type { MediaTextCue, MediaTextTrack, MediaTextTrackState } from '../../../core/media/state'; +import type { TextTrackLike } from '../../../core/media/types'; import { definePlayerFeature } from '../../feature'; +import { isMediaTextTrackCapable, isQuerySelectorAllCapable } from '../../media/predicate'; export const textTrackFeature = definePlayerFeature({ name: 'textTrack', @@ -12,13 +14,16 @@ export const textTrackFeature = definePlayerFeature({ textTrackList: [], subtitlesShowing: false, toggleSubtitles(forceShow?: boolean) { + const { media } = target(); + if (!isMediaTextTrackCapable(media)) return false; + const subtitlesTracks = getTextTrackList( - target().media, + media, (track) => track.kind === 'subtitles' || track.kind === 'captions' ); if (!subtitlesTracks.length) return false; - const showing = subtitlesTracks.some((track: TextTrack) => track.mode === 'showing'); + const showing = subtitlesTracks.some((track) => track.mode === 'showing'); const nextShowing = forceShow ?? !showing; for (const track of subtitlesTracks) { @@ -32,15 +37,17 @@ export const textTrackFeature = definePlayerFeature({ attach({ target, signal, set }) { const { media } = target; + if (!isMediaTextTrackCapable(media)) return; + let trackCleanup: AbortController | null = null; - function sync() { + const sync = () => { trackCleanup?.abort(); trackCleanup = new AbortController(); - let chaptersTrack: TextTrack | null = null; - let thumbnailTrack: TextTrack | null = null; - const textTrackList: MediaTextTrack[] = []; + let chaptersTrack: TextTrackLike | null = null; + let thumbnailTrack: TextTrackLike | null = null; + const textTrackList: MediaTextTrack[] = []; let subtitlesShowing = false; for (let i = 0; i < media.textTracks.length; i++) { @@ -49,7 +56,7 @@ export const textTrackFeature = definePlayerFeature({ if (!thumbnailTrack && track.kind === 'metadata' && track.label === 'thumbnails') thumbnailTrack = track; textTrackList.push({ - kind: track.kind, + kind: track.kind as TextTrackKind, label: track.label, language: track.language, mode: track.mode, @@ -78,9 +85,8 @@ export const textTrackFeature = definePlayerFeature({ // Listen for load events on tracks that don't have cues yet. // `addtrack` fires before cues are parsed — we need the `load` event // on the element to know when cues are ready. - const tracks = media.querySelectorAll?.('track') ?? []; - // For a CustomMediaElement, the functional tracks are in the shadow root. - const shadowTracks = media.shadowRoot?.querySelectorAll?.('track') ?? []; + const tracks = (isQuerySelectorAllCapable<'track'>(media) && media.querySelectorAll('track')) || []; + const shadowTracks = (media instanceof HTMLElement && media.shadowRoot?.querySelectorAll('track')) || []; for (const trackEl of [...tracks, ...shadowTracks]) { if (!trackEl.track?.cues?.length) { @@ -89,13 +95,16 @@ export const textTrackFeature = definePlayerFeature({ } set({ chaptersCues, thumbnailCues, thumbnailTrackSrc, textTrackList, subtitlesShowing }); - } + }; sync(); - listen(media.textTracks, 'addtrack', sync, { signal }); - listen(media.textTracks, 'removetrack', sync, { signal }); - listen(media.textTracks, 'change', sync, { signal }); + const textTracks = media.textTracks; + if (textTracks instanceof EventTarget) { + listen(textTracks, 'addtrack', sync, { signal }); + listen(textTracks, 'removetrack', sync, { signal }); + listen(textTracks, 'change', sync, { signal }); + } listen(media, 'loadstart', sync, { signal }); signal.addEventListener('abort', () => trackCleanup?.abort(), { once: true }); diff --git a/packages/core/src/dom/store/features/time.ts b/packages/core/src/dom/store/features/time.ts index 1dfff6c7..25dc41f1 100644 --- a/packages/core/src/dom/store/features/time.ts +++ b/packages/core/src/dom/store/features/time.ts @@ -2,7 +2,7 @@ import { listen, onEvent } from '@videojs/utils/dom'; import { noop } from '@videojs/utils/function'; import type { MediaTimeState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; -import { hasMetadata } from '../../media/predicate'; +import { hasMetadata, isMediaSeekCapable, isMediaSourceCapable } from '../../media/predicate'; import { signalKeys } from '../signal-keys'; export const timeFeature = definePlayerFeature({ @@ -15,18 +15,15 @@ export const timeFeature = definePlayerFeature({ const { media } = target(), signal = signals.supersede(signalKeys.seek); - // If metadata isn't loaded, wait for it before seeking to avoid errors. + if (!isMediaSeekCapable(media) || !isMediaSourceCapable(media)) return 0; + if (!hasMetadata(media)) { const loaded = await onEvent(media, 'loadedmetadata', { signal }).catch(() => false); if (!loaded) return media.currentTime; } - // Perform the seek and wait for it to complete. const clampedTime = Math.max(0, Math.min(time, media.duration || Infinity)); - // Optimistic update: reflect the target position immediately so UI consumers - // (e.g. time slider) don't snap back to the old currentTime while waiting - // for the browser's async seeking/seeked events. set({ currentTime: clampedTime, seeking: true }); media.currentTime = clampedTime; @@ -39,6 +36,8 @@ export const timeFeature = definePlayerFeature({ attach({ target, signal, set, get }) { const { media } = target; + if (!isMediaSeekCapable(media)) return; + const sync = () => set({ currentTime: media.currentTime, diff --git a/packages/core/src/dom/store/features/volume.ts b/packages/core/src/dom/store/features/volume.ts index 22409afc..ea809492 100644 --- a/packages/core/src/dom/store/features/volume.ts +++ b/packages/core/src/dom/store/features/volume.ts @@ -1,7 +1,8 @@ import { listen } from '@videojs/utils/dom'; - -import type { MediaFeatureAvailability, MediaVolumeState } from '../../../core/media/state'; +import type { MediaVolumeState } from '../../../core/media/state'; +import type { MediaFeatureAvailability } from '../../../core/media/types'; import { definePlayerFeature } from '../../feature'; +import { isMediaVolumeCapable } from '../../media/predicate'; /** Volume to restore when unmuting at zero. */ const UNMUTE_VOLUME = 0.25; @@ -15,9 +16,9 @@ export const volumeFeature = definePlayerFeature({ setVolume(volume: number) { const { media } = target(); + if (!isMediaVolumeCapable(media)) return 0; const clamped = Math.max(0, Math.min(1, volume)); - // Auto-unmute when raising volume above zero. if (clamped > 0 && media.muted) { media.muted = false; } @@ -28,11 +29,11 @@ export const volumeFeature = definePlayerFeature({ toggleMuted() { const { media } = target(); + if (!isMediaVolumeCapable(media)) return false; const effectivelyMuted = media.muted || media.volume === 0; if (effectivelyMuted) { media.muted = false; - // Restore a sensible volume when unmuting at zero. if (media.volume === 0) media.volume = UNMUTE_VOLUME; } else { media.muted = true; @@ -45,6 +46,8 @@ export const volumeFeature = definePlayerFeature({ attach({ target, signal, set }) { const { media } = target; + if (!isMediaVolumeCapable(media)) return; + set({ volumeAvailability: canSetVolume() }); const sync = () => set({ volume: media.volume, muted: media.muted }); diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index dd23d7a3..ceece40d 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -32,8 +32,8 @@ const createConfig = (mode: BuildMode): UserConfig => ({ mode === 'dev' ? { build: true, - // Unified tsconfig covering both core and dom sources. - // Needs DOM libs to preserve MediaApiMixin return types. + // Unified tsconfig covering both core and dom sources + // so DOM lib types are available for dom subpath exports. tsconfig: 'tsconfig.dts.json', } : false, diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 2ec719d3..94d1e664 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -1,5 +1,4 @@ // Core -export { DelegateMixin } from '@videojs/core'; export * from '@videojs/core/dom'; // Store diff --git a/packages/html/src/media/dash-video/index.ts b/packages/html/src/media/dash-video/index.ts index cf722041..269e4ac0 100644 --- a/packages/html/src/media/dash-video/index.ts +++ b/packages/html/src/media/dash-video/index.ts @@ -1,18 +1,5 @@ -import { DashCustomMedia, DashMediaDelegate } from '@videojs/core/dom/media/dash'; +import { CustomMediaElement } from '@videojs/core/dom/media/custom-media-element'; +import { DashMedia } from '@videojs/core/dom/media/dash'; import { MediaAttachMixin } from '../../store/media-attach-mixin'; -import { MediaPropsMixin } from '../../utils/media-props-mixin'; -export class DashVideo extends MediaPropsMixin(MediaAttachMixin(DashCustomMedia), DashMediaDelegate) { - constructor() { - super(); - this.attach(this.target); - } - - disconnectedCallback(): void { - super.disconnectedCallback?.(); - - if (!this.hasAttribute('keep-alive')) { - this.destroy(); - } - } -} +export class DashVideo extends MediaAttachMixin(CustomMediaElement('video', DashMedia)) {} diff --git a/packages/html/src/media/hls-video/index.ts b/packages/html/src/media/hls-video/index.ts index 6c27c234..3ae74393 100644 --- a/packages/html/src/media/hls-video/index.ts +++ b/packages/html/src/media/hls-video/index.ts @@ -1,22 +1,5 @@ -import { HlsCustomMedia, HlsMediaDelegate } from '@videojs/core/dom/media/hls'; +import { CustomMediaElement } from '@videojs/core/dom/media/custom-media-element'; +import { HlsMedia } from '@videojs/core/dom/media/hls'; import { MediaAttachMixin } from '../../store/media-attach-mixin'; -import { MediaPropsMixin } from '../../utils/media-props-mixin'; -export class HlsVideo extends MediaPropsMixin(MediaAttachMixin(HlsCustomMedia), HlsMediaDelegate) { - constructor() { - super(); - // TODO: If we like to support native media elements that - // are appended after the custom element is created, we need to - // attach the native element to the Media API after the native element - // is appended to the DOM. This is currently not supported. - this.attach(this.target); - } - - disconnectedCallback(): void { - super.disconnectedCallback?.(); - - if (!this.hasAttribute('keep-alive')) { - this.destroy(); - } - } -} +export class HlsVideo extends MediaAttachMixin(CustomMediaElement('video', HlsMedia)) {} diff --git a/packages/html/src/media/mux-audio/index.ts b/packages/html/src/media/mux-audio/index.ts index 0f1709c7..17785383 100644 --- a/packages/html/src/media/mux-audio/index.ts +++ b/packages/html/src/media/mux-audio/index.ts @@ -1,18 +1,5 @@ -import { MuxCustomAudio, MuxMediaDelegate } from '@videojs/core/dom/media/mux'; +import { CustomMediaElement } from '@videojs/core/dom/media/custom-media-element'; +import { MuxAudioMedia } from '@videojs/core/dom/media/mux'; import { MediaAttachMixin } from '../../store/media-attach-mixin'; -import { MediaPropsMixin } from '../../utils/media-props-mixin'; -export class MuxAudio extends MediaPropsMixin(MediaAttachMixin(MuxCustomAudio), MuxMediaDelegate) { - constructor() { - super(); - this.attach(this.target); - } - - disconnectedCallback(): void { - super.disconnectedCallback?.(); - - if (!this.hasAttribute('keep-alive')) { - this.destroy(); - } - } -} +export class MuxAudio extends MediaAttachMixin(CustomMediaElement('audio', MuxAudioMedia)) {} diff --git a/packages/html/src/media/mux-video/index.ts b/packages/html/src/media/mux-video/index.ts index 0539598a..0c35fa03 100644 --- a/packages/html/src/media/mux-video/index.ts +++ b/packages/html/src/media/mux-video/index.ts @@ -1,18 +1,5 @@ -import { MuxCustomVideo, MuxMediaDelegate } from '@videojs/core/dom/media/mux'; +import { CustomMediaElement } from '@videojs/core/dom/media/custom-media-element'; +import { MuxVideoMedia } from '@videojs/core/dom/media/mux'; import { MediaAttachMixin } from '../../store/media-attach-mixin'; -import { MediaPropsMixin } from '../../utils/media-props-mixin'; -export class MuxVideo extends MediaPropsMixin(MediaAttachMixin(MuxCustomVideo), MuxMediaDelegate) { - constructor() { - super(); - this.attach(this.target); - } - - disconnectedCallback(): void { - super.disconnectedCallback?.(); - - if (!this.hasAttribute('keep-alive')) { - this.destroy(); - } - } -} +export class MuxVideo extends MediaAttachMixin(CustomMediaElement('video', MuxVideoMedia)) {} diff --git a/packages/html/src/media/native-hls-video/index.ts b/packages/html/src/media/native-hls-video/index.ts index a732f7f3..4548daa6 100644 --- a/packages/html/src/media/native-hls-video/index.ts +++ b/packages/html/src/media/native-hls-video/index.ts @@ -1,18 +1,5 @@ -import { NativeHlsCustomMedia, NativeHlsMediaDelegate } from '@videojs/core/dom/media/native-hls'; +import { CustomMediaElement } from '@videojs/core/dom/media/custom-media-element'; +import { NativeHlsMedia } from '@videojs/core/dom/media/native-hls'; import { MediaAttachMixin } from '../../store/media-attach-mixin'; -import { MediaPropsMixin } from '../../utils/media-props-mixin'; -export class NativeHlsVideo extends MediaPropsMixin(MediaAttachMixin(NativeHlsCustomMedia), NativeHlsMediaDelegate) { - constructor() { - super(); - this.attach(this.target); - } - - disconnectedCallback(): void { - super.disconnectedCallback?.(); - - if (!this.hasAttribute('keep-alive')) { - this.destroy(); - } - } -} +export class NativeHlsVideo extends MediaAttachMixin(CustomMediaElement('video', NativeHlsMedia)) {} diff --git a/packages/html/src/media/simple-hls-video/index.ts b/packages/html/src/media/simple-hls-video/index.ts index 7bfecc2b..1475616a 100644 --- a/packages/html/src/media/simple-hls-video/index.ts +++ b/packages/html/src/media/simple-hls-video/index.ts @@ -1,11 +1,5 @@ -import { SimpleHlsCustomMedia } from '@videojs/core/dom/media/simple-hls'; -import { SpfMedia } from '@videojs/spf/dom'; +import { CustomMediaElement } from '@videojs/core/dom/media/custom-media-element'; +import { SimpleHlsMedia } from '@videojs/core/dom/media/simple-hls'; import { MediaAttachMixin } from '../../store/media-attach-mixin'; -import { MediaPropsMixin } from '../../utils/media-props-mixin'; -export class SimpleHlsVideo extends MediaPropsMixin(MediaAttachMixin(SimpleHlsCustomMedia), SpfMedia) { - constructor() { - super(); - this.attach(this.target); - } -} +export class SimpleHlsVideo extends MediaAttachMixin(CustomMediaElement('video', SimpleHlsMedia)) {} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e8e44bdb..9f4ccb1e 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,7 +1,6 @@ 'use client'; // Core -export { DelegateMixin } from '@videojs/core'; export * from '@videojs/core/dom'; // Store diff --git a/packages/react/src/media/dash-video/index.tsx b/packages/react/src/media/dash-video/index.tsx index dc48f427..73abf2f1 100644 --- a/packages/react/src/media/dash-video/index.tsx +++ b/packages/react/src/media/dash-video/index.tsx @@ -1,7 +1,7 @@ 'use client'; -import type { InferDelegateProps } from '@videojs/core'; -import { DashMedia, DashMediaDelegate } from '@videojs/core/dom/media/dash'; +import { DashMedia } from '@videojs/core/dom/media/dash'; +import type { InferClassProps } from '@videojs/utils/types'; import type { PropsWithChildren, VideoHTMLAttributes } from 'react'; import { forwardRef } from 'react'; import { attachMediaElement } from '../../utils/attach-media-element'; @@ -10,7 +10,7 @@ import { useComposedRefs } from '../../utils/use-composed-refs'; import { useMediaInstance } from '../../utils/use-media-instance'; export type DashVideoProps = PropsWithChildren> & - InferDelegateProps; + InferClassProps; export const DashVideo = forwardRef(function DashVideo({ children, ...props }, ref) { const mediaApi = useMediaInstance(DashMedia); @@ -18,7 +18,7 @@ export const DashVideo = forwardRef(function D const composedRef = useComposedRefs(attachMediaElement(mediaApi), ref); return ( -