diff --git a/apps/sandbox/templates/html-native-hls-video/index.html b/apps/sandbox/templates/html-native-hls-video/index.html
new file mode 100644
index 00000000..669273ca
--- /dev/null
+++ b/apps/sandbox/templates/html-native-hls-video/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Sandbox — HTML Video
+
+
+
+
+
+
+
+
diff --git a/apps/sandbox/templates/html-native-hls-video/main.ts b/apps/sandbox/templates/html-native-hls-video/main.ts
new file mode 100644
index 00000000..ae48fd39
--- /dev/null
+++ b/apps/sandbox/templates/html-native-hls-video/main.ts
@@ -0,0 +1,44 @@
+import '@app/styles.css';
+import '@videojs/html/video/player';
+import '@videojs/html/media/native-hls-video';
+import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state';
+import { loadVideoSkinTag } from '@app/shared/html/skins';
+import { renderStoryboard } from '@app/shared/html/storyboard';
+import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
+import { getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources';
+
+const html = String.raw;
+
+const state = createHtmlSandboxState();
+const loadLatest = createLatestLoader();
+
+async function render() {
+ const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling));
+ if (!tag) return;
+
+ const storyboard = getStoryboardSrc(state.source);
+ const poster = getPosterSrc(state.source);
+
+ document.getElementById('root')!.innerHTML = html`
+
+ <${tag} class="w-full aspect-video max-w-4xl mx-auto">
+
+ ${renderStoryboard(storyboard)}
+
+ ${poster ? html`
` : ''}
+ ${tag}>
+
+ `;
+}
+
+render();
+
+onSkinChange((skin) => {
+ state.skin = skin;
+ render();
+});
+
+onSourceChange((source) => {
+ state.source = source;
+ render();
+});
diff --git a/apps/sandbox/templates/react-native-hls-video/index.html b/apps/sandbox/templates/react-native-hls-video/index.html
new file mode 100644
index 00000000..11c035c7
--- /dev/null
+++ b/apps/sandbox/templates/react-native-hls-video/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Sandbox — React Video
+
+
+
+
+
+
+
+
diff --git a/apps/sandbox/templates/react-native-hls-video/main.tsx b/apps/sandbox/templates/react-native-hls-video/main.tsx
new file mode 100644
index 00000000..f1202a41
--- /dev/null
+++ b/apps/sandbox/templates/react-native-hls-video/main.tsx
@@ -0,0 +1,42 @@
+import '@app/styles.css';
+import { VideoProvider } from '@app/shared/react/providers';
+import { VideoSkinComponent } from '@app/shared/react/skins';
+import { Storyboard } from '@app/shared/react/storyboard';
+import { usePoster } from '@app/shared/react/use-poster';
+import { useSkin } from '@app/shared/react/use-skin';
+import { useSource } from '@app/shared/react/use-source';
+import { useStoryboard } from '@app/shared/react/use-storyboard';
+import { SOURCES } from '@app/shared/sources';
+import type { Styling } from '@app/types';
+import { NativeHlsVideo } from '@videojs/react/media/native-hls-video';
+import { useMemo } from 'react';
+import { createRoot } from 'react-dom/client';
+
+function readStyling(): Styling {
+ return new URLSearchParams(location.search).get('styling') === 'tailwind' ? 'tailwind' : 'css';
+}
+
+function App() {
+ const skin = useSkin();
+ const source = useSource();
+ const styling = useMemo(readStyling, []);
+ const poster = usePoster();
+ const storyboard = useStoryboard();
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+createRoot(document.getElementById('root')!).render();
diff --git a/packages/core/src/core/media/delegate.ts b/packages/core/src/core/media/delegate.ts
index 066177e8..8b0610fb 100644
--- a/packages/core/src/core/media/delegate.ts
+++ b/packages/core/src/core/media/delegate.ts
@@ -1,17 +1,7 @@
import type { Constructor } from '@videojs/utils/types';
-
+import { bridgeEvents } from '../utils/bridge-events';
import { defineClassPropHooks } from '../utils/define-class-prop-hooks';
-/** Wrap `source.dispatchEvent` so every event is also re-dispatched on `target`. */
-export function bridgeEvents(source: EventTarget, target: EventTarget): void {
- const origDispatch = source.dispatchEvent.bind(source);
- source.dispatchEvent = (event: Event): boolean => {
- const result = origDispatch(event);
- target.dispatchEvent(new (event.constructor as typeof Event)(event.type, event));
- return result;
- };
-}
-
export interface Delegate {
attach?(target: EventTarget): void;
detach?(): void;
@@ -70,8 +60,8 @@ export function DelegateMixin, D extends Cons
}
attach(target: EventTarget): void {
- super.attach?.(target);
this.#delegate.attach?.(target);
+ super.attach?.(target);
}
detach(): void {
diff --git a/packages/core/src/core/media/proxy.ts b/packages/core/src/core/media/proxy.ts
index 1c2c825b..f932ef77 100644
--- a/packages/core/src/core/media/proxy.ts
+++ b/packages/core/src/core/media/proxy.ts
@@ -1,6 +1,15 @@
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.
*
@@ -16,8 +25,9 @@ export const ProxyMixin = (
PrimaryClass: AnyConstructor,
...AdditionalClasses: AnyConstructor[]
) => {
- class MediaProxy {
+ class MediaProxyImpl extends EventTarget {
#target: EventTarget | null = null;
+ #types = new Set();
get target() {
return this.#target;
@@ -41,10 +51,16 @@ export const ProxyMixin = (
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;
}
@@ -53,25 +69,21 @@ export const ProxyMixin = (
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
): void {
- this.#target?.addEventListener(type, listener, options);
+ if (!this.#types.has(type)) {
+ this.#types.add(type);
+ this.#target?.addEventListener(type, this.#forwardEvent);
+ }
+ super.addEventListener(type, listener, options);
}
- removeEventListener(
- type: string,
- listener: EventListenerOrEventListenerObject,
- options?: boolean | EventListenerOptions
- ): void {
- this.#target?.removeEventListener(type, listener, options);
- }
-
- dispatchEvent(event: Event): boolean {
- return this.#target?.dispatchEvent(event) ?? false;
- }
+ #forwardEvent = (event: Event) => {
+ this.dispatchEvent(new (event.constructor as typeof Event)(event.type, event));
+ };
}
for (const Class of [PrimaryClass, ...AdditionalClasses]) {
- defineClassPropHooks(MediaProxy, Class.prototype);
+ defineClassPropHooks(MediaProxyImpl, Class.prototype);
}
- return MediaProxy as unknown as Constructor;
+ return MediaProxyImpl as unknown as Constructor;
};
diff --git a/packages/core/src/core/media/tests/delegate.test.ts b/packages/core/src/core/media/tests/delegate.test.ts
index ccf97c1a..a588f619 100644
--- a/packages/core/src/core/media/tests/delegate.test.ts
+++ b/packages/core/src/core/media/tests/delegate.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { DelegateMixin } from '../delegate';
+import { ProxyMixin } from '../proxy';
class FakeBase extends EventTarget {
get(_prop: string): any {}
@@ -58,4 +59,34 @@ describe('DelegateMixin', () => {
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
new file mode 100644
index 00000000..2236a57c
--- /dev/null
+++ b/packages/core/src/core/media/tests/proxy.test.ts
@@ -0,0 +1,180 @@
+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('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/utils/bridge-events.ts b/packages/core/src/core/utils/bridge-events.ts
new file mode 100644
index 00000000..169fcfb0
--- /dev/null
+++ b/packages/core/src/core/utils/bridge-events.ts
@@ -0,0 +1,6 @@
+/** Wrap `source.dispatchEvent` so every event is also re-dispatched on `target`. */
+export function bridgeEvents(source: EventTarget, target: EventTarget): void {
+ if (!source.dispatchEvent) return;
+ source.dispatchEvent = (event: Event) =>
+ target.dispatchEvent(new (event.constructor as typeof Event)(event.type, event));
+}
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 f675ea62..60b75949 100644
--- a/packages/core/src/dom/media/custom-media-element/index.ts
+++ b/packages/core/src/dom/media/custom-media-element/index.ts
@@ -337,16 +337,23 @@ export function CustomMediaMixin>(
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, true);
+ this.shadowRoot!.addEventListener(type, this.#deferForwardEvent, true);
}
}
- handleEvent(event: Event): void {
- if (event.target === this.target) {
- this.dispatchEvent(new CustomEvent(event.type, { detail: (event as CustomEvent).detail }));
+ #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 });
}
- }
+ };
+
+ #forwardEvent = (event: Event) => {
+ this.dispatchEvent(new (event.constructor as typeof Event)(event.type, event));
+ };
#syncMediaChildren(): void {
const removeNativeChildren = new Map(this.#childMap);
diff --git a/packages/core/src/dom/media/dash/index.ts b/packages/core/src/dom/media/dash/index.ts
index eeedaceb..cfb8f02f 100644
--- a/packages/core/src/dom/media/dash/index.ts
+++ b/packages/core/src/dom/media/dash/index.ts
@@ -1,10 +1,10 @@
import * as dashjs from 'dashjs';
-import { DelegateMixin } from '../../../core/media/delegate';
+import { type Delegate, DelegateMixin } from '../../../core/media/delegate';
import { CustomVideoElement } from '../custom-media-element';
import { VideoProxy } from '../proxy';
-export class DashMediaDelegate {
+export class DashMediaDelegate implements Delegate {
#engine: dashjs.MediaPlayerClass;
#src: string = '';
diff --git a/packages/core/src/dom/media/hls/errors.ts b/packages/core/src/dom/media/hls/errors.ts
index 1c5f2173..30706f73 100644
--- a/packages/core/src/dom/media/hls/errors.ts
+++ b/packages/core/src/dom/media/hls/errors.ts
@@ -17,8 +17,8 @@ const hlsErrorTypeToCode: Record = {
[Hls.ErrorTypes.OTHER_ERROR]: MediaError.MEDIA_ERR_CUSTOM,
};
-export function HlsMediaErrorsMixin>(BaseClass: Base) {
- class HlsMediaErrors extends (BaseClass as Constructor) {
+export function HlsJsMediaErrorsMixin>(BaseClass: Base) {
+ class HlsJsMediaErrors extends (BaseClass as Constructor) {
#disconnect: AbortController | null = null;
#error: MediaError | null = null;
@@ -51,7 +51,7 @@ export function HlsMediaErrorsMixin>(Bas
if (!data.fatal) return;
const code = hlsErrorTypeToCode[data.type] ?? MediaError.MEDIA_ERR_CUSTOM;
- const error = new MediaError(data.error, code, true, data.details);
+ const error = new MediaError(data.error?.message, code, true, data.details);
error.data = data;
this.#error = error;
@@ -73,5 +73,5 @@ export function HlsMediaErrorsMixin>(Bas
}
}
- return HlsMediaErrors as unknown as Base & Constructor<{ readonly error: MediaError | null }>;
+ return HlsJsMediaErrors as unknown as Base & Constructor<{ readonly error: MediaError | null }>;
}
diff --git a/packages/core/src/dom/media/hls/hlsjs.ts b/packages/core/src/dom/media/hls/hlsjs.ts
index 0a674271..70cae2d7 100644
--- a/packages/core/src/dom/media/hls/hlsjs.ts
+++ b/packages/core/src/dom/media/hls/hlsjs.ts
@@ -1,7 +1,8 @@
import Hls, { type HlsConfig } from 'hls.js';
-import { HlsMediaErrorsMixin } from './errors';
-import { HlsMediaPreloadMixin } from './preload';
-import { HlsMediaTextTracksMixin } from './text-tracks';
+import type { Delegate } from '../../../core/media/delegate';
+import { HlsJsMediaErrorsMixin } from './errors';
+import { HlsJsMediaPreloadMixin } from './preload';
+import { HlsJsMediaTextTracksMixin } from './text-tracks';
export const defaultHlsConfig: Partial = {
backBufferLength: 30,
@@ -12,7 +13,7 @@ export const defaultHlsConfig: Partial = {
autoStartLoad: false,
};
-class HlsJsMediaDelegateBase extends EventTarget {
+class HlsJsMediaDelegateBase extends EventTarget implements Delegate {
#engine: Hls | null = null;
constructor(params: { config: Partial }) {
@@ -53,6 +54,6 @@ class HlsJsMediaDelegateBase extends EventTarget {
}
}
-export class HlsJsMediaDelegate extends HlsMediaPreloadMixin(
- HlsMediaTextTracksMixin(HlsMediaErrorsMixin(HlsJsMediaDelegateBase))
+export class HlsJsMediaDelegate extends HlsJsMediaPreloadMixin(
+ HlsJsMediaTextTracksMixin(HlsJsMediaErrorsMixin(HlsJsMediaDelegateBase))
) {}
diff --git a/packages/core/src/dom/media/hls/index.ts b/packages/core/src/dom/media/hls/index.ts
index d0116a01..3271fc07 100644
--- a/packages/core/src/dom/media/hls/index.ts
+++ b/packages/core/src/dom/media/hls/index.ts
@@ -1,6 +1,7 @@
import { shallowEqual } from '@videojs/utils/object';
import Hls from 'hls.js';
-import { bridgeEvents, DelegateMixin } from '../../../core/media/delegate';
+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';
@@ -23,7 +24,7 @@ export const SourceTypes = {
MP4: 'video/mp4',
};
-export class HlsMediaDelegate extends EventTarget {
+export class HlsMediaDelegate extends EventTarget implements Delegate {
#target: HTMLMediaElement | null = null;
#delegate: HlsJsMediaDelegate | NativeHlsMediaDelegate | null = null;
#src: string = '';
diff --git a/packages/core/src/dom/media/hls/preload.ts b/packages/core/src/dom/media/hls/preload.ts
index ebb59eb4..8addf5a5 100644
--- a/packages/core/src/dom/media/hls/preload.ts
+++ b/packages/core/src/dom/media/hls/preload.ts
@@ -16,8 +16,8 @@ export type PreloadType = '' | 'none' | 'metadata' | 'auto';
* - `'metadata'` → minimal buffer (1 byte / 1 second), deferred full load on play.
* - `'none'` / `''` → no start, deferred full load on play.
*/
-export function HlsMediaPreloadMixin>(BaseClass: Base) {
- class HlsMediaPreload extends (BaseClass as Constructor) {
+export function HlsJsMediaPreloadMixin>(BaseClass: Base) {
+ class HlsJsMediaPreload extends (BaseClass as Constructor) {
#preloadAbort: AbortController | null = null;
#preload: PreloadType = 'metadata';
#defaultMaxBufferLength: number | undefined;
@@ -91,5 +91,5 @@ export function HlsMediaPreloadMixin>(Ba
}
}
- return HlsMediaPreload as unknown as Base & Constructor<{ preload: PreloadType }>;
+ return HlsJsMediaPreload as unknown as Base & Constructor<{ preload: PreloadType }>;
}
diff --git a/packages/core/src/dom/media/hls/tests/errors.test.ts b/packages/core/src/dom/media/hls/tests/errors.test.ts
index 4fe3a54b..92aa8a1c 100644
--- a/packages/core/src/dom/media/hls/tests/errors.test.ts
+++ b/packages/core/src/dom/media/hls/tests/errors.test.ts
@@ -2,7 +2,7 @@ import Hls from 'hls.js';
import { describe, expect, it, vi } from 'vitest';
import { MediaError } from '../../../../core/media/media-error';
-import { HlsMediaErrorsMixin } from '../errors';
+import { HlsJsMediaErrorsMixin } from '../errors';
class FakeHost extends EventTarget {
engine: Hls | null;
@@ -22,7 +22,7 @@ class FakeHost extends EventTarget {
}
}
-const HlsMediaErrors = HlsMediaErrorsMixin(FakeHost);
+const HlsJsMediaErrors = HlsJsMediaErrorsMixin(FakeHost);
function createEngine(): Hls {
const listeners = new Map void>>();
@@ -42,14 +42,14 @@ function createEngine(): Hls {
function setup() {
const engine = createEngine();
- const host = new HlsMediaErrors(engine);
+ const host = new HlsJsMediaErrors(engine);
const video = document.createElement('video');
host.attach(video);
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
return { engine, host, video };
}
-describe('HlsMediaErrorsMixin', () => {
+describe('HlsJsMediaErrorsMixin', () => {
it('dispatches an error event on the host for fatal errors', () => {
const { engine, host } = setup();
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 cd9bf602..ce8b0883 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,7 @@
import Hls from 'hls.js';
import { describe, expect, it, vi } from 'vitest';
-import { type HlsEngineHost, HlsMediaPreloadMixin } from '../preload';
+import { type HlsEngineHost, HlsJsMediaPreloadMixin } from '../preload';
function createEngine(): Hls {
const listeners = new Map void>>();
@@ -34,9 +34,9 @@ class FakeHost implements HlsEngineHost {
}
}
-const PreloadHost = HlsMediaPreloadMixin(FakeHost);
+const PreloadHost = HlsJsMediaPreloadMixin(FakeHost);
-describe('HlsMediaPreloadMixin', () => {
+describe('HlsJsMediaPreloadMixin', () => {
it('defaults preload to metadata', () => {
const host = new PreloadHost(null);
expect(host.preload).toBe('metadata');
diff --git a/packages/core/src/dom/media/hls/text-tracks.ts b/packages/core/src/dom/media/hls/text-tracks.ts
index 112e5bec..21453d86 100644
--- a/packages/core/src/dom/media/hls/text-tracks.ts
+++ b/packages/core/src/dom/media/hls/text-tracks.ts
@@ -18,8 +18,8 @@ interface HlsEngineHost {
* forwards cues into them. It also syncs user track-mode changes back to
* hls.js via `engine.subtitleTrack`.
*/
-export function HlsMediaTextTracksMixin>(BaseClass: Base) {
- class HlsMediaTextTracks extends (BaseClass as Constructor) {
+export function HlsJsMediaTextTracksMixin>(BaseClass: Base) {
+ class HlsJsMediaTextTracks extends (BaseClass as Constructor) {
#disconnect: AbortController | null = null;
constructor(...args: any[]) {
@@ -145,7 +145,7 @@ export function HlsMediaTextTracksMixin>
}
}
- return HlsMediaTextTracks as unknown as Base;
+ return HlsJsMediaTextTracks as unknown as Base;
}
function addTextTrack(
diff --git a/packages/core/src/dom/media/native-hls/errors.ts b/packages/core/src/dom/media/native-hls/errors.ts
new file mode 100644
index 00000000..523702a8
--- /dev/null
+++ b/packages/core/src/dom/media/native-hls/errors.ts
@@ -0,0 +1,75 @@
+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;
+}
+
+export function NativeHlsMediaErrorsMixin>(BaseClass: Base) {
+ class NativeHlsMediaErrors extends (BaseClass as Constructor) {
+ #disconnect: AbortController | null = null;
+ #error: MediaError | null = null;
+
+ get error(): MediaError | null {
+ return this.#error;
+ }
+
+ attach(target: HTMLMediaElement): void {
+ super.attach(target);
+ this.#init(target);
+ }
+
+ detach(): void {
+ this.#destroy();
+ super.detach();
+ }
+
+ destroy(): void {
+ this.#destroy();
+ super.destroy();
+ }
+
+ #destroy(): void {
+ this.#disconnect?.abort();
+ this.#disconnect = null;
+ this.#error = null;
+ }
+
+ #init(target: HTMLMediaElement): void {
+ this.#destroy();
+ this.#disconnect = new AbortController();
+
+ const signal = this.#disconnect.signal;
+
+ target.addEventListener(
+ 'error',
+ (event) => {
+ event.stopImmediatePropagation();
+
+ const native = target.error;
+ if (!native) return;
+
+ const error = new MediaError(native.message, native.code, true);
+ this.#error = error;
+
+ this.dispatchEvent(new ErrorEvent('error', { error, message: error.message }));
+ },
+ { signal }
+ );
+
+ target.addEventListener(
+ 'emptied',
+ () => {
+ this.#error = null;
+ },
+ { signal }
+ );
+ }
+ }
+
+ return NativeHlsMediaErrors as unknown as Base & Constructor<{ readonly error: MediaError | null }>;
+}
diff --git a/packages/core/src/dom/media/native-hls/index.ts b/packages/core/src/dom/media/native-hls/index.ts
index ef5a73a6..c6cdaf02 100644
--- a/packages/core/src/dom/media/native-hls/index.ts
+++ b/packages/core/src/dom/media/native-hls/index.ts
@@ -1,10 +1,11 @@
-import { DelegateMixin } from '../../../core/media/delegate';
+import { type Delegate, DelegateMixin } from '../../../core/media/delegate';
import { CustomVideoElement } from '../custom-media-element';
import { VideoProxy } from '../proxy';
+import { NativeHlsMediaErrorsMixin } from './errors';
export type PreloadType = '' | 'none' | 'metadata' | 'auto';
-export class NativeHlsMediaDelegate extends EventTarget {
+class NativeHlsMediaDelegateBase extends EventTarget implements Delegate {
#target: HTMLMediaElement | null = null;
#src: string = '';
#preload: PreloadType = 'metadata';
@@ -17,10 +18,6 @@ export class NativeHlsMediaDelegate extends EventTarget {
return null;
}
- get error() {
- return this.target?.error ?? null;
- }
-
get src() {
return this.#src;
}
@@ -66,6 +63,8 @@ export class NativeHlsMediaDelegate extends EventTarget {
}
}
+export class NativeHlsMediaDelegate extends NativeHlsMediaErrorsMixin(NativeHlsMediaDelegateBase) {}
+
export class NativeHlsCustomMedia extends DelegateMixin(CustomVideoElement, NativeHlsMediaDelegate) {}
export class NativeHlsMedia extends DelegateMixin(VideoProxy, NativeHlsMediaDelegate) {}
diff --git a/packages/core/src/dom/media/native-hls/tests/errors.test.ts b/packages/core/src/dom/media/native-hls/tests/errors.test.ts
new file mode 100644
index 00000000..ecb07a63
--- /dev/null
+++ b/packages/core/src/dom/media/native-hls/tests/errors.test.ts
@@ -0,0 +1,194 @@
+import { describe, expect, it, vi } from 'vitest';
+import { MediaError } from '../../../../core/media/media-error';
+import { NativeHlsMediaErrorsMixin, type NativeMediaHost } from '../errors';
+
+class FakeHost extends EventTarget implements NativeMediaHost {
+ target: HTMLMediaElement | null = null;
+
+ attach(target: HTMLMediaElement): void {
+ this.target = target;
+ }
+
+ detach(): void {
+ this.target = null;
+ }
+
+ destroy(): void {
+ this.target = null;
+ }
+}
+
+const NativeHlsMediaErrors = NativeHlsMediaErrorsMixin(FakeHost);
+
+function setup() {
+ const host = new NativeHlsMediaErrors();
+ const video = document.createElement('video');
+ host.attach(video);
+ return { host, video };
+}
+
+function fireNativeError(video: HTMLVideoElement, code: number, message = '') {
+ Object.defineProperty(video, 'error', {
+ value: { code, message },
+ configurable: true,
+ });
+ video.dispatchEvent(new Event('error'));
+}
+
+describe('NativeHlsMediaErrorsMixin', () => {
+ it('dispatches an error event with a MediaError for native errors', () => {
+ const { host, video } = setup();
+
+ const handler = vi.fn();
+ host.addEventListener('error', handler);
+
+ fireNativeError(video, MediaError.MEDIA_ERR_NETWORK, 'network failure');
+
+ expect(handler).toHaveBeenCalledOnce();
+
+ const event = handler.mock.calls[0]![0] as ErrorEvent;
+ expect(event.error).toBeInstanceOf(MediaError);
+ expect(event.error.code).toBe(MediaError.MEDIA_ERR_NETWORK);
+ expect(event.error.fatal).toBe(true);
+ expect(event.error.message).toBe('network failure');
+ });
+
+ it('uses default message when native error has no message', () => {
+ const { host, video } = setup();
+
+ const handler = vi.fn();
+ host.addEventListener('error', handler);
+
+ fireNativeError(video, MediaError.MEDIA_ERR_DECODE);
+
+ const event = handler.mock.calls[0]![0] as ErrorEvent;
+ expect(event.error.code).toBe(MediaError.MEDIA_ERR_DECODE);
+ expect(event.error.message).toBe(MediaError.defaultMessages[MediaError.MEDIA_ERR_DECODE]);
+ });
+
+ it('exposes the error via the error getter', () => {
+ const { host, video } = setup();
+
+ expect(host.error).toBeNull();
+
+ fireNativeError(video, MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED, 'unsupported');
+
+ expect(host.error).toBeInstanceOf(MediaError);
+ expect(host.error!.code).toBe(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
+ });
+
+ it('stops propagation of the native error event', () => {
+ const { video } = setup();
+
+ const nativeHandler = vi.fn();
+ video.addEventListener('error', nativeHandler);
+
+ fireNativeError(video, MediaError.MEDIA_ERR_NETWORK, 'network failure');
+
+ expect(nativeHandler).not.toHaveBeenCalled();
+ });
+
+ it('maps MEDIA_ERR_ABORTED correctly', () => {
+ const { host, video } = setup();
+
+ const handler = vi.fn();
+ host.addEventListener('error', handler);
+
+ fireNativeError(video, MediaError.MEDIA_ERR_ABORTED);
+
+ const event = handler.mock.calls[0]![0] as ErrorEvent;
+ expect(event.error.code).toBe(MediaError.MEDIA_ERR_ABORTED);
+ });
+
+ it('ignores error events when target.error is null', () => {
+ const { host, video } = setup();
+
+ const handler = vi.fn();
+ host.addEventListener('error', handler);
+
+ video.dispatchEvent(new Event('error'));
+
+ expect(handler).not.toHaveBeenCalled();
+ expect(host.error).toBeNull();
+ });
+
+ it('stops listening after detach', () => {
+ const { host, video } = setup();
+
+ const handler = vi.fn();
+ host.addEventListener('error', handler);
+
+ host.detach();
+
+ fireNativeError(video, MediaError.MEDIA_ERR_NETWORK, 'after detach');
+
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it('resets error after detach', () => {
+ const { host, video } = setup();
+
+ fireNativeError(video, MediaError.MEDIA_ERR_NETWORK, 'failure');
+
+ expect(host.error).not.toBeNull();
+
+ host.detach();
+
+ expect(host.error).toBeNull();
+ });
+
+ it('stops listening after destroy', () => {
+ const { host, video } = setup();
+
+ const handler = vi.fn();
+ host.addEventListener('error', handler);
+
+ host.destroy();
+
+ fireNativeError(video, MediaError.MEDIA_ERR_NETWORK, 'after destroy');
+
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it('resets error after destroy', () => {
+ const { host, video } = setup();
+
+ fireNativeError(video, MediaError.MEDIA_ERR_DECODE, 'failure');
+
+ expect(host.error).not.toBeNull();
+
+ host.destroy();
+
+ expect(host.error).toBeNull();
+ });
+
+ it('clears stale error on source change (emptied event)', () => {
+ const { host, video } = setup();
+
+ fireNativeError(video, MediaError.MEDIA_ERR_NETWORK, 'failure');
+ expect(host.error).not.toBeNull();
+
+ video.dispatchEvent(new Event('emptied'));
+
+ expect(host.error).toBeNull();
+ });
+
+ it('re-initializes on re-attach', () => {
+ const { host } = setup();
+
+ const handler = vi.fn();
+ host.addEventListener('error', handler);
+
+ host.detach();
+
+ const video2 = document.createElement('video');
+ host.attach(video2);
+
+ fireNativeError(video2, MediaError.MEDIA_ERR_NETWORK, 'new target');
+
+ expect(handler).toHaveBeenCalledOnce();
+
+ const event = handler.mock.calls[0]![0] as ErrorEvent;
+ expect(event.error.code).toBe(MediaError.MEDIA_ERR_NETWORK);
+ });
+});
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
new file mode 100644
index 00000000..365bef14
--- /dev/null
+++ b/packages/core/src/dom/media/native-hls/tests/native-hls-custom-media.test.ts
@@ -0,0 +1,47 @@
+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'));
+}
+
+afterEach(() => {
+ document.body.innerHTML = '';
+});
+
+describe('NativeHlsCustomMedia', () => {
+ 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 = el.shadowRoot!.querySelector('video')! as HTMLVideoElement;
+ (el as any).attach(video);
+
+ const handler = vi.fn();
+ el.addEventListener('error', handler);
+
+ 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);
+ expect(event.error.message).toBe('network failure');
+ });
+});