-
Notifications
You must be signed in to change notification settings - Fork 49
feat(core): add error handling to Hls.js media #1164
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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] ?? ''; | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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>>>> | ||
| >; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }>; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Error object as message adds "Error: " prefix
Medium Severity
Passing
data.error(anErrorobject) as the first argument tonew MediaError(data.error, ...)causes thesuper(message)call to invokeToString()on the Error, which callsError.prototype.toString(). This produces"Error: <original message>"instead of just"<original message>". Additionally, if hls.js provides an Error with an empty message,toString()returns"Error"(truthy), preventingdefaultMessagesfrom being used as a fallback. Passingdata.error?.messageinstead ofdata.errorwould preserve the original message string.Additional Locations (1)
packages/core/src/core/media/media-error.ts#L38-L48