diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index 4339caeb..648e1551 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -1,6 +1,7 @@ export * from './media/delegate'; export * from './media/proxy'; export * from './media/state'; +export * from './media/types'; export * from './ui/alert-dialog/alert-dialog-core'; export * from './ui/alert-dialog/alert-dialog-data-attrs'; export * from './ui/buffering-indicator/buffering-indicator-core'; diff --git a/packages/core/src/core/media/delegate.ts b/packages/core/src/core/media/delegate.ts index 2ed2e9a0..066177e8 100644 --- a/packages/core/src/core/media/delegate.ts +++ b/packages/core/src/core/media/delegate.ts @@ -2,27 +2,28 @@ import type { Constructor } from '@videojs/utils/types'; 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; } -// 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; - -type WritableKeys = { - [K in keyof T]-?: IfEquals<{ [Q in K]: T[K] }, { -readonly [Q in K]: T[K] }, K, never>; -}[keyof T]; - -type SettableKeys = { - [K in WritableKeys]: T[K] extends (...args: any[]) => any ? never : K; -}[WritableKeys]; - -type ExcludeInternal = K extends `_${string}` ? never : K; - -export type InferDelegateProps any> = Partial< - Pick, ExcludeInternal>>> ->; +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 @@ -31,13 +32,21 @@ export type InferDelegateProps any> = * * Works with both `CustomMediaMixin` and `ProxyMixin`. */ -export function DelegateMixin, D extends Constructor>( +export function DelegateMixin, D extends Constructor>( BaseClass: Base, DelegateClass: D ) { - class DelegateImpl extends (BaseClass as Constructor) { + 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]; @@ -75,12 +84,5 @@ export function DelegateMixin, D extends Construct defineClassPropHooks(DelegateImpl, proto); } - return DelegateImpl as unknown as Constructor< - InstanceType & - InstanceType & { - attach(target: EventTarget): void; - detach(): void; - } - > & - Omit; + return DelegateImpl as unknown as Constructor & InstanceType> & Omit; } diff --git a/packages/core/src/core/media/media-error.ts b/packages/core/src/core/media/media-error.ts new file mode 100644 index 00000000..b525188d --- /dev/null +++ b/packages/core/src/core/media/media-error.ts @@ -0,0 +1,50 @@ +// Typescript says it's strictly a string, but it can also be a number or an object with a toString method. +// https://github.com/microsoft/TypeScript/issues/6032 +// https://262.ecma-international.org/6.0/#sec-error-message + +type Stringable = string | { toString(): string }; + +declare global { + interface ErrorConstructor { + new (message?: Stringable): Error; + (message?: Stringable): Error; + readonly prototype: Error; + } +} + +export class MediaError extends Error { + static MEDIA_ERR_ABORTED = 1 as const; + static MEDIA_ERR_NETWORK = 2 as const; + static MEDIA_ERR_DECODE = 3 as const; + static MEDIA_ERR_SRC_NOT_SUPPORTED = 4 as const; + static MEDIA_ERR_ENCRYPTED = 5 as const; + // Technically this is Mux specific but it's generic enough to be used here. + // @see https://docs.mux.com/guides/data/monitor-html5-video-element#customize-error-tracking-behavior + static MEDIA_ERR_CUSTOM = 100 as const; + + static defaultMessages: Record = { + 1: 'You aborted the media playback', + 2: 'A network error caused the media download to fail.', + 3: 'A media error caused playback to be aborted. The media could be corrupt or your browser does not support this format.', + 4: 'An unsupported error occurred. The server or network failed, or your browser does not support this format.', + 5: 'The media is encrypted and there are no keys to decrypt it.', + }; + + name: string; + code: number; + context: string | undefined; + fatal: boolean; + data?: any; + + constructor(message?: Stringable, code: number = MediaError.MEDIA_ERR_CUSTOM, fatal?: boolean, context?: string) { + super(message); + this.name = 'MediaError'; + this.code = code; + this.context = context; + this.fatal = fatal ?? (code >= MediaError.MEDIA_ERR_NETWORK && code <= MediaError.MEDIA_ERR_ENCRYPTED); + + if (!this.message) { + this.message = MediaError.defaultMessages[this.code] ?? ''; + } + } +} diff --git a/packages/core/src/core/media/proxy.ts b/packages/core/src/core/media/proxy.ts index 1a3414d5..77d7e769 100644 --- a/packages/core/src/core/media/proxy.ts +++ b/packages/core/src/core/media/proxy.ts @@ -16,7 +16,7 @@ export const ProxyMixin = ( PrimaryClass: AnyConstructor, ...AdditionalClasses: AnyConstructor[] ) => { - class MediaProxy { + class MediaProxy extends EventTarget { #target: EventTarget | null = null; get target() { diff --git a/packages/core/src/core/media/tests/delegate.test.ts b/packages/core/src/core/media/tests/delegate.test.ts new file mode 100644 index 00000000..ccf97c1a --- /dev/null +++ b/packages/core/src/core/media/tests/delegate.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { DelegateMixin } from '../delegate'; + +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 {} +} + +class EventfulDelegate extends EventTarget { + attach(_target: EventTarget): void {} + detach(): void {} + + fire(): void { + this.dispatchEvent(new Event('custom')); + } +} + +const Mixed = DelegateMixin(FakeBase, EventfulDelegate); + +describe('DelegateMixin', () => { + describe('event forwarding', () => { + it('forwards events dispatched by the delegate to the host', () => { + const host = new Mixed(); + const handler = vi.fn(); + host.addEventListener('custom', handler); + + host.fire(); + + expect(handler).toHaveBeenCalledOnce(); + }); + + it('creates a new event instance for the host dispatch', () => { + const host = new Mixed(); + const hostEvents: Event[] = []; + host.addEventListener('custom', (e) => hostEvents.push(e)); + + 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(); + }); + }); +}); diff --git a/packages/core/src/core/media/types.ts b/packages/core/src/core/media/types.ts new file mode 100644 index 00000000..ebba17f6 --- /dev/null +++ b/packages/core/src/core/media/types.ts @@ -0,0 +1,16 @@ +// 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; + +type WritableKeys = { + [K in keyof T]-?: IfEquals<{ [Q in K]: T[K] }, { -readonly [Q in K]: T[K] }, K, never>; +}[keyof T]; + +type SettableKeys = { + [K in WritableKeys]: T[K] extends (...args: any[]) => any ? never : K; +}[WritableKeys]; + +type ExcludeInternal = K extends `_${string}` ? never : K; + +export type InferDelegateProps any> = Partial< + Pick, ExcludeInternal>>> +>; diff --git a/packages/core/src/dom/media/hls/errors.ts b/packages/core/src/dom/media/hls/errors.ts new file mode 100644 index 00000000..1c5f2173 --- /dev/null +++ b/packages/core/src/dom/media/hls/errors.ts @@ -0,0 +1,77 @@ +import type { Constructor } 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; +} + +const hlsErrorTypeToCode: Record = { + [Hls.ErrorTypes.NETWORK_ERROR]: MediaError.MEDIA_ERR_NETWORK, + [Hls.ErrorTypes.MEDIA_ERROR]: MediaError.MEDIA_ERR_DECODE, + [Hls.ErrorTypes.KEY_SYSTEM_ERROR]: MediaError.MEDIA_ERR_ENCRYPTED, + [Hls.ErrorTypes.MUX_ERROR]: MediaError.MEDIA_ERR_DECODE, + [Hls.ErrorTypes.OTHER_ERROR]: MediaError.MEDIA_ERR_CUSTOM, +}; + +export function HlsMediaErrorsMixin>(BaseClass: Base) { + class HlsMediaErrors extends (BaseClass as Constructor) { + #disconnect: AbortController | null = null; + #error: MediaError | null = null; + + constructor(...args: any[]) { + super(...args); + + this.engine?.on(Hls.Events.MANIFEST_LOADING, () => this.#init()); + this.engine?.on(Hls.Events.MEDIA_ATTACHED, () => this.#init()); + this.engine?.on(Hls.Events.MEDIA_DETACHED, () => this.#destroy()); + this.engine?.on(Hls.Events.DESTROYING, () => this.#destroy()); + } + + get error(): MediaError | null { + return this.#error; + } + + #destroy(): void { + this.#disconnect?.abort(); + this.#disconnect = null; + } + + #init(): void { + this.#disconnect?.abort(); + this.#disconnect = new AbortController(); + + const { engine, target } = this; + if (!engine || !target) return; + + const onError = (_event: string, data: ErrorData) => { + if (!data.fatal) return; + + const code = hlsErrorTypeToCode[data.type] ?? MediaError.MEDIA_ERR_CUSTOM; + const error = new MediaError(data.error, code, true, data.details); + error.data = data; + + this.#error = error; + + const event = new ErrorEvent('error', { error, message: error.message }); + this.dispatchEvent(event); + }; + + engine.on(Hls.Events.ERROR, onError); + + this.#disconnect.signal.addEventListener( + 'abort', + () => { + engine.off(Hls.Events.ERROR, onError); + this.#error = null; + }, + { once: true } + ); + } + } + + return HlsMediaErrors 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 eb9cacc5..0a674271 100644 --- a/packages/core/src/dom/media/hls/hlsjs.ts +++ b/packages/core/src/dom/media/hls/hlsjs.ts @@ -1,4 +1,5 @@ import Hls, { type HlsConfig } from 'hls.js'; +import { HlsMediaErrorsMixin } from './errors'; import { HlsMediaPreloadMixin } from './preload'; import { HlsMediaTextTracksMixin } from './text-tracks'; @@ -11,10 +12,11 @@ export const defaultHlsConfig: Partial = { autoStartLoad: false, }; -class HlsJsMediaDelegateBase { +class HlsJsMediaDelegateBase extends EventTarget { #engine: Hls | null = null; constructor(params: { config: Partial }) { + super(); this.#engine = new Hls({ ...defaultHlsConfig, ...params.config, @@ -51,4 +53,6 @@ class HlsJsMediaDelegateBase { } } -export class HlsJsMediaDelegate extends HlsMediaPreloadMixin(HlsMediaTextTracksMixin(HlsJsMediaDelegateBase)) {} +export class HlsJsMediaDelegate extends HlsMediaPreloadMixin( + HlsMediaTextTracksMixin(HlsMediaErrorsMixin(HlsJsMediaDelegateBase)) +) {} diff --git a/packages/core/src/dom/media/hls/index.ts b/packages/core/src/dom/media/hls/index.ts index 906a8f66..d0116a01 100644 --- a/packages/core/src/dom/media/hls/index.ts +++ b/packages/core/src/dom/media/hls/index.ts @@ -1,6 +1,6 @@ import { shallowEqual } from '@videojs/utils/object'; import Hls from 'hls.js'; -import { DelegateMixin } from '../../../core/media/delegate'; +import { bridgeEvents, DelegateMixin } from '../../../core/media/delegate'; import { CustomVideoElement } from '../custom-media-element'; import { NativeHlsMediaDelegate } from '../native-hls'; import { VideoProxy } from '../proxy'; @@ -23,7 +23,7 @@ export const SourceTypes = { MP4: 'video/mp4', }; -export class HlsMediaDelegate { +export class HlsMediaDelegate extends EventTarget { #target: HTMLMediaElement | null = null; #delegate: HlsJsMediaDelegate | NativeHlsMediaDelegate | null = null; #src: string = ''; @@ -43,6 +43,10 @@ export class HlsMediaDelegate { return this.#delegate?.engine ?? null; } + get error() { + return this.#delegate?.error ?? null; + } + get src() { return this.#src; } @@ -130,6 +134,8 @@ export class HlsMediaDelegate { ? new HlsJsMediaDelegate({ config: { ...this.config, debug: this.debug } }) : new NativeHlsMediaDelegate(); + bridgeEvents(this.#delegate, this); + if (this.target) { this.#delegate.attach(this.target); } diff --git a/packages/core/src/dom/media/hls/tests/errors.test.ts b/packages/core/src/dom/media/hls/tests/errors.test.ts new file mode 100644 index 00000000..4fe3a54b --- /dev/null +++ b/packages/core/src/dom/media/hls/tests/errors.test.ts @@ -0,0 +1,195 @@ +import Hls from 'hls.js'; +import { describe, expect, it, vi } from 'vitest'; + +import { MediaError } from '../../../../core/media/media-error'; +import { HlsMediaErrorsMixin } from '../errors'; + +class FakeHost extends EventTarget { + engine: Hls | null; + target: HTMLMediaElement | null = null; + + constructor(engine: Hls | null = null) { + super(); + this.engine = engine; + } + + attach(target: EventTarget): void { + this.target = target as HTMLMediaElement; + } + + detach(): void { + this.target = null; + } +} + +const HlsMediaErrors = HlsMediaErrorsMixin(FakeHost); + +function createEngine(): Hls { + const listeners = new Map void>>(); + return { + on(event: string, fn: (...args: any[]) => void) { + if (!listeners.has(event)) listeners.set(event, new Set()); + listeners.get(event)!.add(fn); + }, + off(event: string, fn: (...args: any[]) => void) { + listeners.get(event)?.delete(fn); + }, + emit(event: string, ...args: any[]) { + for (const fn of listeners.get(event) ?? []) fn(event, ...args); + }, + } as unknown as Hls; +} + +function setup() { + const engine = createEngine(); + const host = new HlsMediaErrors(engine); + const video = document.createElement('video'); + host.attach(video); + (engine as any).emit(Hls.Events.MEDIA_ATTACHED); + return { engine, host, video }; +} + +describe('HlsMediaErrorsMixin', () => { + it('dispatches an error event on the host for fatal errors', () => { + const { engine, host } = setup(); + + const handler = vi.fn(); + host.addEventListener('error', handler); + + (engine as any).emit(Hls.Events.ERROR, { + type: Hls.ErrorTypes.NETWORK_ERROR, + details: Hls.ErrorDetails.MANIFEST_LOAD_ERROR, + fatal: true, + error: new Error('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.context).toBe(Hls.ErrorDetails.MANIFEST_LOAD_ERROR); + expect(event.error.data).toBeDefined(); + }); + + it('exposes the error via the error getter', () => { + const { engine, host } = setup(); + + expect(host.error).toBeNull(); + + (engine as any).emit(Hls.Events.ERROR, { + type: Hls.ErrorTypes.NETWORK_ERROR, + details: Hls.ErrorDetails.MANIFEST_LOAD_ERROR, + fatal: true, + error: new Error('network failure'), + }); + + expect(host.error).toBeInstanceOf(MediaError); + expect(host.error!.code).toBe(MediaError.MEDIA_ERR_NETWORK); + }); + + it('ignores non-fatal errors', () => { + const { engine, host } = setup(); + + const handler = vi.fn(); + host.addEventListener('error', handler); + + (engine as any).emit(Hls.Events.ERROR, { + type: Hls.ErrorTypes.NETWORK_ERROR, + details: Hls.ErrorDetails.FRAG_LOAD_ERROR, + fatal: false, + error: new Error('transient'), + }); + + expect(handler).not.toHaveBeenCalled(); + expect(host.error).toBeNull(); + }); + + it('maps media errors to MEDIA_ERR_DECODE', () => { + const { engine, host } = setup(); + + const handler = vi.fn(); + host.addEventListener('error', handler); + + (engine as any).emit(Hls.Events.ERROR, { + type: Hls.ErrorTypes.MEDIA_ERROR, + details: Hls.ErrorDetails.BUFFER_APPEND_ERROR, + fatal: true, + error: new Error('decode'), + }); + + const event = handler.mock.calls[0]![0] as ErrorEvent; + expect(event.error.code).toBe(MediaError.MEDIA_ERR_DECODE); + }); + + it('maps key system errors to MEDIA_ERR_ENCRYPTED', () => { + const { engine, host } = setup(); + + const handler = vi.fn(); + host.addEventListener('error', handler); + + (engine as any).emit(Hls.Events.ERROR, { + type: Hls.ErrorTypes.KEY_SYSTEM_ERROR, + details: Hls.ErrorDetails.KEY_SYSTEM_NO_KEYS, + fatal: true, + error: new Error('drm'), + }); + + const event = handler.mock.calls[0]![0] as ErrorEvent; + expect(event.error.code).toBe(MediaError.MEDIA_ERR_ENCRYPTED); + }); + + it('stops listening after MEDIA_DETACHED', () => { + const { engine, host } = setup(); + + const handler = vi.fn(); + host.addEventListener('error', handler); + + (engine as any).emit(Hls.Events.MEDIA_DETACHED); + + (engine as any).emit(Hls.Events.ERROR, { + type: Hls.ErrorTypes.NETWORK_ERROR, + details: Hls.ErrorDetails.MANIFEST_LOAD_ERROR, + fatal: true, + error: new Error('after detach'), + }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('resets error after MEDIA_DETACHED', () => { + const { engine, host } = setup(); + + (engine as any).emit(Hls.Events.ERROR, { + type: Hls.ErrorTypes.NETWORK_ERROR, + details: Hls.ErrorDetails.MANIFEST_LOAD_ERROR, + fatal: true, + error: new Error('failure'), + }); + + expect(host.error).not.toBeNull(); + + (engine as any).emit(Hls.Events.MEDIA_DETACHED); + + expect(host.error).toBeNull(); + }); + + it('preserves the original hls.js error as the message source', () => { + const { engine, host } = setup(); + + const handler = vi.fn(); + host.addEventListener('error', handler); + + (engine as any).emit(Hls.Events.ERROR, { + type: Hls.ErrorTypes.OTHER_ERROR, + details: Hls.ErrorDetails.INTERNAL_EXCEPTION, + fatal: true, + error: new Error('something broke'), + }); + + const event = handler.mock.calls[0]![0] as ErrorEvent; + expect(event.error.code).toBe(MediaError.MEDIA_ERR_CUSTOM); + expect(event.error.message).toContain('something broke'); + }); +}); diff --git a/packages/core/src/dom/media/native-hls/index.ts b/packages/core/src/dom/media/native-hls/index.ts index 37b62f13..2e1c4fd9 100644 --- a/packages/core/src/dom/media/native-hls/index.ts +++ b/packages/core/src/dom/media/native-hls/index.ts @@ -4,7 +4,7 @@ import { VideoProxy } from '../proxy'; export type PreloadType = '' | 'none' | 'metadata' | 'auto'; -export class NativeHlsMediaDelegate { +export class NativeHlsMediaDelegate extends EventTarget { #target: HTMLMediaElement | null = null; #src: string = ''; #preload: PreloadType = 'metadata'; @@ -17,6 +17,10 @@ export class NativeHlsMediaDelegate { return null; } + get error() { + return this.target?.error ?? null; + } + get src() { return this.#src; }