feat(core): add error handling to Hls.js media (#1164)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Wesley Luyten
2026-03-31 19:05:41 -05:00
committed by GitHub
co-authored by Cursor Agent
parent 3430fe1a49
commit 9e2ede6ddc
11 changed files with 448 additions and 32 deletions
+1
View File
@@ -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';
+28 -26
View File
@@ -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<X, Y, A, B> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? A : B;
type WritableKeys<T> = {
[K in keyof T]-?: IfEquals<{ [Q in K]: T[K] }, { -readonly [Q in K]: T[K] }, K, never>;
}[keyof T];
type SettableKeys<T> = {
[K in WritableKeys<T>]: T[K] extends (...args: any[]) => any ? never : K;
}[WritableKeys<T>];
type ExcludeInternal<K> = K extends `_${string}` ? never : K;
export type InferDelegateProps<D extends abstract new (...args: any[]) => any> = Partial<
Pick<InstanceType<D>, ExcludeInternal<SettableKeys<InstanceType<D>>>>
>;
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<D extends abstract new (...args: any[]) => any> =
*
* Works with both `CustomMediaMixin` and `ProxyMixin`.
*/
export function DelegateMixin<Base extends Constructor<any>, D extends Constructor<Delegate>>(
export function DelegateMixin<Base extends Constructor<BaseType>, D extends Constructor<Delegate>>(
BaseClass: Base,
DelegateClass: D
) {
class DelegateImpl extends (BaseClass as Constructor<any>) {
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<Base extends Constructor<any>, D extends Construct
defineClassPropHooks(DelegateImpl, proto);
}
return DelegateImpl as unknown as Constructor<
InstanceType<Base> &
InstanceType<D> & {
attach(target: EventTarget): void;
detach(): void;
}
> &
Omit<Base, 'prototype'>;
return DelegateImpl as unknown as Constructor<InstanceType<Base> & InstanceType<D>> & Omit<Base, 'prototype'>;
}
@@ -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<number, string> = {
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] ?? '';
}
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ export const ProxyMixin = <T extends EventTarget>(
PrimaryClass: AnyConstructor<T>,
...AdditionalClasses: AnyConstructor<EventTarget>[]
) => {
class MediaProxy {
class MediaProxy extends EventTarget {
#target: EventTarget | null = null;
get target() {
@@ -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();
});
});
});
+16
View File
@@ -0,0 +1,16 @@
// Detects readonly vs writable properties via conditional type identity check.
type IfEquals<X, Y, A, B> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? A : B;
type WritableKeys<T> = {
[K in keyof T]-?: IfEquals<{ [Q in K]: T[K] }, { -readonly [Q in K]: T[K] }, K, never>;
}[keyof T];
type SettableKeys<T> = {
[K in WritableKeys<T>]: T[K] extends (...args: any[]) => any ? never : K;
}[WritableKeys<T>];
type ExcludeInternal<K> = K extends `_${string}` ? never : K;
export type InferDelegateProps<D extends abstract new (...args: any[]) => any> = Partial<
Pick<InstanceType<D>, ExcludeInternal<SettableKeys<InstanceType<D>>>>
>;
+77
View File
@@ -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<string, number> = {
[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<Base extends Constructor<HlsEngineHost>>(BaseClass: Base) {
class HlsMediaErrors extends (BaseClass as Constructor<HlsEngineHost>) {
#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 }>;
}
+6 -2
View File
@@ -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<HlsConfig> = {
autoStartLoad: false,
};
class HlsJsMediaDelegateBase {
class HlsJsMediaDelegateBase extends EventTarget {
#engine: Hls | null = null;
constructor(params: { config: Partial<HlsConfig> }) {
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))
) {}
+8 -2
View File
@@ -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);
}
@@ -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<string, Set<(...args: any[]) => 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');
});
});
@@ -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;
}