Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/core/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
54 changes: 28 additions & 26 deletions packages/core/src/core/media/delegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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];
Expand Down Expand Up @@ -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'>;
}
50 changes: 50 additions & 0 deletions packages/core/src/core/media/media-error.ts
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] ?? '';
}
}
}
2 changes: 1 addition & 1 deletion packages/core/src/core/media/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
61 changes: 61 additions & 0 deletions packages/core/src/core/media/tests/delegate.test.ts
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();
});
});
});
16 changes: 16 additions & 0 deletions packages/core/src/core/media/types.ts
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>>>>
>;
77 changes: 77 additions & 0 deletions packages/core/src/dom/media/hls/errors.ts
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);
Copy link
Copy Markdown

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 (an Error object) as the first argument to new MediaError(data.error, ...) causes the super(message) call to invoke ToString() on the Error, which calls Error.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), preventing defaultMessages from being used as a fallback. Passing data.error?.message instead of data.error would preserve the original message string.

Additional Locations (1)
Fix in Cursor Fix in Web

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 }>;
}
8 changes: 6 additions & 2 deletions packages/core/src/dom/media/hls/hlsjs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Hls, { type HlsConfig } from 'hls.js';
import { HlsMediaErrorsMixin } from './errors';
import { HlsMediaPreloadMixin } from './preload';
import { HlsMediaTextTracksMixin } from './text-tracks';

Expand All @@ -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,
Expand Down Expand Up @@ -51,4 +53,6 @@ class HlsJsMediaDelegateBase {
}
}

export class HlsJsMediaDelegate extends HlsMediaPreloadMixin(HlsMediaTextTracksMixin(HlsJsMediaDelegateBase)) {}
export class HlsJsMediaDelegate extends HlsMediaPreloadMixin(
HlsMediaTextTracksMixin(HlsMediaErrorsMixin(HlsJsMediaDelegateBase))
) {}
10 changes: 8 additions & 2 deletions packages/core/src/dom/media/hls/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 = '';
Expand All @@ -43,6 +43,10 @@ export class HlsMediaDelegate {
return this.#delegate?.engine ?? null;
}

get error() {
return this.#delegate?.error ?? null;
}

get src() {
return this.#src;
}
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading