From 5239b9b834ff882e63f258852b307cdc5f5c5090 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Thu, 2 Apr 2026 22:25:26 -0500 Subject: [PATCH] feat(core): add native hls error handling (#1190) --- .../html-native-hls-video/index.html | 14 ++ .../templates/html-native-hls-video/main.ts | 44 ++++ .../react-native-hls-video/index.html | 14 ++ .../templates/react-native-hls-video/main.tsx | 42 ++++ packages/core/src/core/media/delegate.ts | 14 +- packages/core/src/core/media/proxy.ts | 42 ++-- .../src/core/media/tests/delegate.test.ts | 31 +++ .../core/src/core/media/tests/proxy.test.ts | 180 ++++++++++++++++ packages/core/src/core/utils/bridge-events.ts | 6 + .../dom/media/custom-media-element/index.ts | 17 +- packages/core/src/dom/media/dash/index.ts | 4 +- packages/core/src/dom/media/hls/errors.ts | 8 +- packages/core/src/dom/media/hls/hlsjs.ts | 13 +- packages/core/src/dom/media/hls/index.ts | 5 +- packages/core/src/dom/media/hls/preload.ts | 6 +- .../src/dom/media/hls/tests/errors.test.ts | 8 +- .../src/dom/media/hls/tests/preload.test.ts | 6 +- .../core/src/dom/media/hls/text-tracks.ts | 6 +- .../core/src/dom/media/native-hls/errors.ts | 75 +++++++ .../core/src/dom/media/native-hls/index.ts | 11 +- .../dom/media/native-hls/tests/errors.test.ts | 194 ++++++++++++++++++ .../tests/native-hls-custom-media.test.ts | 47 +++++ 22 files changed, 722 insertions(+), 65 deletions(-) create mode 100644 apps/sandbox/templates/html-native-hls-video/index.html create mode 100644 apps/sandbox/templates/html-native-hls-video/main.ts create mode 100644 apps/sandbox/templates/react-native-hls-video/index.html create mode 100644 apps/sandbox/templates/react-native-hls-video/main.tsx create mode 100644 packages/core/src/core/media/tests/proxy.test.ts create mode 100644 packages/core/src/core/utils/bridge-events.ts create mode 100644 packages/core/src/dom/media/native-hls/errors.ts create mode 100644 packages/core/src/dom/media/native-hls/tests/errors.test.ts create mode 100644 packages/core/src/dom/media/native-hls/tests/native-hls-custom-media.test.ts 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`Video poster` : ''} + + + `; +} + +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'); + }); +});