mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 13:48:14 +00:00
feat(core): add error handling to Hls.js media (#1164)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor Agent
parent
3430fe1a49
commit
9e2ede6ddc
@@ -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';
|
||||
|
||||
@@ -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] ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>>>>
|
||||
>;
|
||||
@@ -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 }>;
|
||||
}
|
||||
@@ -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))
|
||||
) {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user