mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add native hls error handling (#1190)
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sandbox — HTML Video</title>
|
||||
<link rel="preconnect" href="https://rsms.me/" />
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||
</head>
|
||||
<body class="font-sans p-2">
|
||||
<div id="root" class="flex justify-center items-center min-h-screen"></div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
import '@app/styles.css';
|
||||
import '@videojs/html/video/player';
|
||||
import '@videojs/html/media/native-hls-video';
|
||||
import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state';
|
||||
import { loadVideoSkinTag } from '@app/shared/html/skins';
|
||||
import { renderStoryboard } from '@app/shared/html/storyboard';
|
||||
import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
|
||||
import { getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources';
|
||||
|
||||
const html = String.raw;
|
||||
|
||||
const state = createHtmlSandboxState();
|
||||
const loadLatest = createLatestLoader();
|
||||
|
||||
async function render() {
|
||||
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling));
|
||||
if (!tag) return;
|
||||
|
||||
const storyboard = getStoryboardSrc(state.source);
|
||||
const poster = getPosterSrc(state.source);
|
||||
|
||||
document.getElementById('root')!.innerHTML = html`
|
||||
<video-player>
|
||||
<${tag} class="w-full aspect-video max-w-4xl mx-auto">
|
||||
<native-hls-video src="${SOURCES[state.source].url}" playsinline crossorigin="anonymous">
|
||||
${renderStoryboard(storyboard)}
|
||||
</native-hls-video>
|
||||
${poster ? html`<img slot="poster" src="${poster}" alt="Video poster" />` : ''}
|
||||
</${tag}>
|
||||
</video-player>
|
||||
`;
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
onSkinChange((skin) => {
|
||||
state.skin = skin;
|
||||
render();
|
||||
});
|
||||
|
||||
onSourceChange((source) => {
|
||||
state.source = source;
|
||||
render();
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sandbox — React Video</title>
|
||||
<link rel="preconnect" href="https://rsms.me/" />
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||
</head>
|
||||
<body class="font-sans p-2">
|
||||
<div id="root" class="flex justify-center items-center min-h-screen"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
import '@app/styles.css';
|
||||
import { VideoProvider } from '@app/shared/react/providers';
|
||||
import { VideoSkinComponent } from '@app/shared/react/skins';
|
||||
import { Storyboard } from '@app/shared/react/storyboard';
|
||||
import { usePoster } from '@app/shared/react/use-poster';
|
||||
import { useSkin } from '@app/shared/react/use-skin';
|
||||
import { useSource } from '@app/shared/react/use-source';
|
||||
import { useStoryboard } from '@app/shared/react/use-storyboard';
|
||||
import { SOURCES } from '@app/shared/sources';
|
||||
import type { Styling } from '@app/types';
|
||||
import { NativeHlsVideo } from '@videojs/react/media/native-hls-video';
|
||||
import { useMemo } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
function readStyling(): Styling {
|
||||
return new URLSearchParams(location.search).get('styling') === 'tailwind' ? 'tailwind' : 'css';
|
||||
}
|
||||
|
||||
function App() {
|
||||
const skin = useSkin();
|
||||
const source = useSource();
|
||||
const styling = useMemo(readStyling, []);
|
||||
const poster = usePoster();
|
||||
const storyboard = useStoryboard();
|
||||
|
||||
return (
|
||||
<VideoProvider>
|
||||
<VideoSkinComponent
|
||||
poster={poster}
|
||||
skin={skin}
|
||||
styling={styling}
|
||||
className="w-full aspect-video max-w-4xl mx-auto"
|
||||
>
|
||||
<NativeHlsVideo src={SOURCES[source].url} playsInline crossOrigin="anonymous">
|
||||
<Storyboard src={storyboard} />
|
||||
</NativeHlsVideo>
|
||||
</VideoSkinComponent>
|
||||
</VideoProvider>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
@@ -1,17 +1,7 @@
|
||||
import type { Constructor } from '@videojs/utils/types';
|
||||
|
||||
import { bridgeEvents } from '../utils/bridge-events';
|
||||
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;
|
||||
@@ -70,8 +60,8 @@ export function DelegateMixin<Base extends Constructor<BaseType>, D extends Cons
|
||||
}
|
||||
|
||||
attach(target: EventTarget): void {
|
||||
super.attach?.(target);
|
||||
this.#delegate.attach?.(target);
|
||||
super.attach?.(target);
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { AnyConstructor, Constructor } from '@videojs/utils/types';
|
||||
import { defineClassPropHooks } from '../utils/define-class-prop-hooks';
|
||||
|
||||
export interface MediaProxy {
|
||||
readonly target: EventTarget | null;
|
||||
get(prop: keyof EventTarget): any;
|
||||
set(prop: keyof EventTarget, val: any): void;
|
||||
call(prop: keyof EventTarget, ...args: any[]): any;
|
||||
attach(target: EventTarget): void;
|
||||
detach(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This mixin creates an API from the passed classes and proxies the methods and properties to the attached target.
|
||||
*
|
||||
@@ -16,8 +25,9 @@ export const ProxyMixin = <T extends EventTarget>(
|
||||
PrimaryClass: AnyConstructor<T>,
|
||||
...AdditionalClasses: AnyConstructor<EventTarget>[]
|
||||
) => {
|
||||
class MediaProxy {
|
||||
class MediaProxyImpl extends EventTarget {
|
||||
#target: EventTarget | null = null;
|
||||
#types = new Set<string>();
|
||||
|
||||
get target() {
|
||||
return this.#target;
|
||||
@@ -41,10 +51,16 @@ export const ProxyMixin = <T extends EventTarget>(
|
||||
attach(target: EventTarget): void {
|
||||
if (!target || this.#target === target) return;
|
||||
this.#target = target;
|
||||
for (const type of this.#types) {
|
||||
target.addEventListener(type, this.#forwardEvent);
|
||||
}
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (!this.#target) return;
|
||||
for (const type of this.#types) {
|
||||
this.#target.removeEventListener(type, this.#forwardEvent);
|
||||
}
|
||||
this.#target = null;
|
||||
}
|
||||
|
||||
@@ -53,25 +69,21 @@ export const ProxyMixin = <T extends EventTarget>(
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
): void {
|
||||
this.#target?.addEventListener(type, listener, options);
|
||||
if (!this.#types.has(type)) {
|
||||
this.#types.add(type);
|
||||
this.#target?.addEventListener(type, this.#forwardEvent);
|
||||
}
|
||||
super.addEventListener(type, listener, options);
|
||||
}
|
||||
|
||||
removeEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | EventListenerOptions
|
||||
): void {
|
||||
this.#target?.removeEventListener(type, listener, options);
|
||||
}
|
||||
|
||||
dispatchEvent(event: Event): boolean {
|
||||
return this.#target?.dispatchEvent(event) ?? false;
|
||||
}
|
||||
#forwardEvent = (event: Event) => {
|
||||
this.dispatchEvent(new (event.constructor as typeof Event)(event.type, event));
|
||||
};
|
||||
}
|
||||
|
||||
for (const Class of [PrimaryClass, ...AdditionalClasses]) {
|
||||
defineClassPropHooks(MediaProxy, Class.prototype);
|
||||
defineClassPropHooks(MediaProxyImpl, Class.prototype);
|
||||
}
|
||||
|
||||
return MediaProxy as unknown as Constructor<T>;
|
||||
return MediaProxyImpl as unknown as Constructor<T & MediaProxy>;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DelegateMixin } from '../delegate';
|
||||
import { ProxyMixin } from '../proxy';
|
||||
|
||||
class FakeBase extends EventTarget {
|
||||
get(_prop: string): any {}
|
||||
@@ -58,4 +59,34 @@ describe('DelegateMixin', () => {
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('attach order with ProxyMixin', () => {
|
||||
it('delegate interceptor fires before proxy forwarder when listener added pre-attach', () => {
|
||||
class InterceptingDelegate extends EventTarget {
|
||||
attach(target: EventTarget): void {
|
||||
target.addEventListener('error', (event) => {
|
||||
event.stopImmediatePropagation();
|
||||
this.dispatchEvent(new CustomEvent('error', { detail: 'enriched' }));
|
||||
});
|
||||
}
|
||||
detach(): void {}
|
||||
}
|
||||
|
||||
const ProxyBase = ProxyMixin(EventTarget);
|
||||
const Mixed = DelegateMixin(ProxyBase, InterceptingDelegate);
|
||||
|
||||
const host = new Mixed();
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('error', handler);
|
||||
|
||||
const target = new EventTarget();
|
||||
host.attach(target);
|
||||
|
||||
target.dispatchEvent(new Event('error'));
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
const event = handler.mock.calls[0]![0] as CustomEvent;
|
||||
expect(event.detail).toBe('enriched');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ProxyMixin } from '../proxy';
|
||||
|
||||
const MediaProxy = ProxyMixin(EventTarget);
|
||||
|
||||
function setup() {
|
||||
const proxy = new MediaProxy();
|
||||
const target = new EventTarget();
|
||||
proxy.attach(target);
|
||||
return { proxy, target };
|
||||
}
|
||||
|
||||
describe('ProxyMixin', () => {
|
||||
describe('event proxying', () => {
|
||||
it('forwards events from target to proxy listeners', () => {
|
||||
const { proxy, target } = setup();
|
||||
const handler = vi.fn();
|
||||
|
||||
proxy.addEventListener('play', handler);
|
||||
target.dispatchEvent(new Event('play'));
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not forward after removeEventListener', () => {
|
||||
const { proxy, target } = setup();
|
||||
const handler = vi.fn();
|
||||
|
||||
proxy.addEventListener('play', handler);
|
||||
proxy.removeEventListener('play', handler);
|
||||
target.dispatchEvent(new Event('play'));
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple listeners for the same event type', () => {
|
||||
it('keeps forwarding to remaining listeners after one is removed', () => {
|
||||
const { proxy, target } = setup();
|
||||
const handlerA = vi.fn();
|
||||
const handlerB = vi.fn();
|
||||
|
||||
proxy.addEventListener('play', handlerA);
|
||||
proxy.addEventListener('play', handlerB);
|
||||
proxy.removeEventListener('play', handlerA);
|
||||
|
||||
target.dispatchEvent(new Event('play'));
|
||||
|
||||
expect(handlerA).not.toHaveBeenCalled();
|
||||
expect(handlerB).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('removes target listener only when all listeners for a type are removed', () => {
|
||||
const { proxy, target } = setup();
|
||||
const handlerA = vi.fn();
|
||||
const handlerB = vi.fn();
|
||||
|
||||
proxy.addEventListener('play', handlerA);
|
||||
proxy.addEventListener('play', handlerB);
|
||||
proxy.removeEventListener('play', handlerA);
|
||||
proxy.removeEventListener('play', handlerB);
|
||||
|
||||
target.dispatchEvent(new Event('play'));
|
||||
|
||||
expect(handlerA).not.toHaveBeenCalled();
|
||||
expect(handlerB).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles interleaved add/remove across multiple types', () => {
|
||||
const { proxy, target } = setup();
|
||||
const playHandler = vi.fn();
|
||||
const pauseHandler = vi.fn();
|
||||
|
||||
proxy.addEventListener('play', playHandler);
|
||||
proxy.addEventListener('pause', pauseHandler);
|
||||
proxy.removeEventListener('play', playHandler);
|
||||
|
||||
target.dispatchEvent(new Event('play'));
|
||||
target.dispatchEvent(new Event('pause'));
|
||||
|
||||
expect(playHandler).not.toHaveBeenCalled();
|
||||
expect(pauseHandler).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('once listeners', () => {
|
||||
it('invokes a once listener exactly once', () => {
|
||||
const { proxy, target } = setup();
|
||||
const handler = vi.fn();
|
||||
|
||||
proxy.addEventListener('play', handler, { once: true });
|
||||
target.dispatchEvent(new Event('play'));
|
||||
target.dispatchEvent(new Event('play'));
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not break other listeners when a once listener fires', () => {
|
||||
const { proxy, target } = setup();
|
||||
const onceHandler = vi.fn();
|
||||
const persistentHandler = vi.fn();
|
||||
|
||||
proxy.addEventListener('play', onceHandler, { once: true });
|
||||
proxy.addEventListener('play', persistentHandler);
|
||||
|
||||
target.dispatchEvent(new Event('play'));
|
||||
target.dispatchEvent(new Event('play'));
|
||||
|
||||
expect(onceHandler).toHaveBeenCalledOnce();
|
||||
expect(persistentHandler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attach / detach with existing listeners', () => {
|
||||
it('re-subscribes existing types on the new target after attach', () => {
|
||||
const proxy = new MediaProxy();
|
||||
const handler = vi.fn();
|
||||
|
||||
proxy.addEventListener('play', handler);
|
||||
|
||||
const target = new EventTarget();
|
||||
proxy.attach(target);
|
||||
|
||||
target.dispatchEvent(new Event('play'));
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('unsubscribes all types from old target on detach', () => {
|
||||
const { proxy, target } = setup();
|
||||
const handler = vi.fn();
|
||||
|
||||
proxy.addEventListener('play', handler);
|
||||
proxy.detach();
|
||||
|
||||
target.dispatchEvent(new Event('play'));
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('transfers listeners when switching targets', () => {
|
||||
const { proxy, target: oldTarget } = setup();
|
||||
const handler = vi.fn();
|
||||
|
||||
proxy.addEventListener('play', handler);
|
||||
proxy.detach();
|
||||
|
||||
const newTarget = new EventTarget();
|
||||
proxy.attach(newTarget);
|
||||
|
||||
oldTarget.dispatchEvent(new Event('play'));
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
newTarget.dispatchEvent(new Event('play'));
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventListenerObject support', () => {
|
||||
it('invokes handleEvent on an object listener', () => {
|
||||
const { proxy, target } = setup();
|
||||
const obj = { handleEvent: vi.fn() };
|
||||
|
||||
proxy.addEventListener('play', obj);
|
||||
target.dispatchEvent(new Event('play'));
|
||||
|
||||
expect(obj.handleEvent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('invokes handleEvent for once object listeners', () => {
|
||||
const { proxy, target } = setup();
|
||||
const obj = { handleEvent: vi.fn() };
|
||||
|
||||
proxy.addEventListener('play', obj, { once: true });
|
||||
target.dispatchEvent(new Event('play'));
|
||||
target.dispatchEvent(new Event('play'));
|
||||
|
||||
expect(obj.handleEvent).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Wrap `source.dispatchEvent` so every event is also re-dispatched on `target`. */
|
||||
export function bridgeEvents(source: EventTarget, target: EventTarget): void {
|
||||
if (!source.dispatchEvent) return;
|
||||
source.dispatchEvent = (event: Event) =>
|
||||
target.dispatchEvent(new (event.constructor as typeof Event)(event.type, event));
|
||||
}
|
||||
@@ -337,16 +337,23 @@ export function CustomMediaMixin<T extends Constructor<HTMLElement>>(
|
||||
this.shadowRoot!.addEventListener('slotchange', () => this.#syncMediaChildren());
|
||||
this.#syncMediaChildren();
|
||||
|
||||
// Media element events don't bubble so we need to capture them on the shadow root.
|
||||
for (const type of (this.constructor as typeof CustomMedia).Events) {
|
||||
this.shadowRoot!.addEventListener(type, this, true);
|
||||
this.shadowRoot!.addEventListener(type, this.#deferForwardEvent, true);
|
||||
}
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
if (event.target === this.target) {
|
||||
this.dispatchEvent(new CustomEvent(event.type, { detail: (event as CustomEvent).detail }));
|
||||
#deferForwardEvent = (event: Event) => {
|
||||
if (this.target && this.target === event.target) {
|
||||
// Add an event listener on the bubbling phase that forwards the event
|
||||
// so consumers can still stop propagation of the event.
|
||||
this.target.addEventListener(event.type, this.#forwardEvent, { once: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#forwardEvent = (event: Event) => {
|
||||
this.dispatchEvent(new (event.constructor as typeof Event)(event.type, event));
|
||||
};
|
||||
|
||||
#syncMediaChildren(): void {
|
||||
const removeNativeChildren = new Map(this.#childMap);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as dashjs from 'dashjs';
|
||||
|
||||
import { DelegateMixin } from '../../../core/media/delegate';
|
||||
import { type Delegate, DelegateMixin } from '../../../core/media/delegate';
|
||||
import { CustomVideoElement } from '../custom-media-element';
|
||||
import { VideoProxy } from '../proxy';
|
||||
|
||||
export class DashMediaDelegate {
|
||||
export class DashMediaDelegate implements Delegate {
|
||||
#engine: dashjs.MediaPlayerClass;
|
||||
#src: string = '';
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ const hlsErrorTypeToCode: Record<string, number> = {
|
||||
[Hls.ErrorTypes.OTHER_ERROR]: MediaError.MEDIA_ERR_CUSTOM,
|
||||
};
|
||||
|
||||
export function HlsMediaErrorsMixin<Base extends Constructor<HlsEngineHost>>(BaseClass: Base) {
|
||||
class HlsMediaErrors extends (BaseClass as Constructor<HlsEngineHost>) {
|
||||
export function HlsJsMediaErrorsMixin<Base extends Constructor<HlsEngineHost>>(BaseClass: Base) {
|
||||
class HlsJsMediaErrors extends (BaseClass as Constructor<HlsEngineHost>) {
|
||||
#disconnect: AbortController | null = null;
|
||||
#error: MediaError | null = null;
|
||||
|
||||
@@ -51,7 +51,7 @@ export function HlsMediaErrorsMixin<Base extends Constructor<HlsEngineHost>>(Bas
|
||||
if (!data.fatal) return;
|
||||
|
||||
const code = hlsErrorTypeToCode[data.type] ?? MediaError.MEDIA_ERR_CUSTOM;
|
||||
const error = new MediaError(data.error, code, true, data.details);
|
||||
const error = new MediaError(data.error?.message, code, true, data.details);
|
||||
error.data = data;
|
||||
|
||||
this.#error = error;
|
||||
@@ -73,5 +73,5 @@ export function HlsMediaErrorsMixin<Base extends Constructor<HlsEngineHost>>(Bas
|
||||
}
|
||||
}
|
||||
|
||||
return HlsMediaErrors as unknown as Base & Constructor<{ readonly error: MediaError | null }>;
|
||||
return HlsJsMediaErrors as unknown as Base & Constructor<{ readonly error: MediaError | null }>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Hls, { type HlsConfig } from 'hls.js';
|
||||
import { HlsMediaErrorsMixin } from './errors';
|
||||
import { HlsMediaPreloadMixin } from './preload';
|
||||
import { HlsMediaTextTracksMixin } from './text-tracks';
|
||||
import type { Delegate } from '../../../core/media/delegate';
|
||||
import { HlsJsMediaErrorsMixin } from './errors';
|
||||
import { HlsJsMediaPreloadMixin } from './preload';
|
||||
import { HlsJsMediaTextTracksMixin } from './text-tracks';
|
||||
|
||||
export const defaultHlsConfig: Partial<HlsConfig> = {
|
||||
backBufferLength: 30,
|
||||
@@ -12,7 +13,7 @@ export const defaultHlsConfig: Partial<HlsConfig> = {
|
||||
autoStartLoad: false,
|
||||
};
|
||||
|
||||
class HlsJsMediaDelegateBase extends EventTarget {
|
||||
class HlsJsMediaDelegateBase extends EventTarget implements Delegate {
|
||||
#engine: Hls | null = null;
|
||||
|
||||
constructor(params: { config: Partial<HlsConfig> }) {
|
||||
@@ -53,6 +54,6 @@ class HlsJsMediaDelegateBase extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
export class HlsJsMediaDelegate extends HlsMediaPreloadMixin(
|
||||
HlsMediaTextTracksMixin(HlsMediaErrorsMixin(HlsJsMediaDelegateBase))
|
||||
export class HlsJsMediaDelegate extends HlsJsMediaPreloadMixin(
|
||||
HlsJsMediaTextTracksMixin(HlsJsMediaErrorsMixin(HlsJsMediaDelegateBase))
|
||||
) {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { shallowEqual } from '@videojs/utils/object';
|
||||
import Hls from 'hls.js';
|
||||
import { bridgeEvents, DelegateMixin } from '../../../core/media/delegate';
|
||||
import { type Delegate, DelegateMixin } from '../../../core/media/delegate';
|
||||
import { bridgeEvents } from '../../../core/utils/bridge-events';
|
||||
import { CustomVideoElement } from '../custom-media-element';
|
||||
import { NativeHlsMediaDelegate } from '../native-hls';
|
||||
import { VideoProxy } from '../proxy';
|
||||
@@ -23,7 +24,7 @@ export const SourceTypes = {
|
||||
MP4: 'video/mp4',
|
||||
};
|
||||
|
||||
export class HlsMediaDelegate extends EventTarget {
|
||||
export class HlsMediaDelegate extends EventTarget implements Delegate {
|
||||
#target: HTMLMediaElement | null = null;
|
||||
#delegate: HlsJsMediaDelegate | NativeHlsMediaDelegate | null = null;
|
||||
#src: string = '';
|
||||
|
||||
@@ -16,8 +16,8 @@ export type PreloadType = '' | 'none' | 'metadata' | 'auto';
|
||||
* - `'metadata'` → minimal buffer (1 byte / 1 second), deferred full load on play.
|
||||
* - `'none'` / `''` → no start, deferred full load on play.
|
||||
*/
|
||||
export function HlsMediaPreloadMixin<Base extends Constructor<HlsEngineHost>>(BaseClass: Base) {
|
||||
class HlsMediaPreload extends (BaseClass as Constructor<HlsEngineHost>) {
|
||||
export function HlsJsMediaPreloadMixin<Base extends Constructor<HlsEngineHost>>(BaseClass: Base) {
|
||||
class HlsJsMediaPreload extends (BaseClass as Constructor<HlsEngineHost>) {
|
||||
#preloadAbort: AbortController | null = null;
|
||||
#preload: PreloadType = 'metadata';
|
||||
#defaultMaxBufferLength: number | undefined;
|
||||
@@ -91,5 +91,5 @@ export function HlsMediaPreloadMixin<Base extends Constructor<HlsEngineHost>>(Ba
|
||||
}
|
||||
}
|
||||
|
||||
return HlsMediaPreload as unknown as Base & Constructor<{ preload: PreloadType }>;
|
||||
return HlsJsMediaPreload as unknown as Base & Constructor<{ preload: PreloadType }>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Hls from 'hls.js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MediaError } from '../../../../core/media/media-error';
|
||||
import { HlsMediaErrorsMixin } from '../errors';
|
||||
import { HlsJsMediaErrorsMixin } from '../errors';
|
||||
|
||||
class FakeHost extends EventTarget {
|
||||
engine: Hls | null;
|
||||
@@ -22,7 +22,7 @@ class FakeHost extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
const HlsMediaErrors = HlsMediaErrorsMixin(FakeHost);
|
||||
const HlsJsMediaErrors = HlsJsMediaErrorsMixin(FakeHost);
|
||||
|
||||
function createEngine(): Hls {
|
||||
const listeners = new Map<string, Set<(...args: any[]) => void>>();
|
||||
@@ -42,14 +42,14 @@ function createEngine(): Hls {
|
||||
|
||||
function setup() {
|
||||
const engine = createEngine();
|
||||
const host = new HlsMediaErrors(engine);
|
||||
const host = new HlsJsMediaErrors(engine);
|
||||
const video = document.createElement('video');
|
||||
host.attach(video);
|
||||
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
|
||||
return { engine, host, video };
|
||||
}
|
||||
|
||||
describe('HlsMediaErrorsMixin', () => {
|
||||
describe('HlsJsMediaErrorsMixin', () => {
|
||||
it('dispatches an error event on the host for fatal errors', () => {
|
||||
const { engine, host } = setup();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Hls from 'hls.js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { type HlsEngineHost, HlsMediaPreloadMixin } from '../preload';
|
||||
import { type HlsEngineHost, HlsJsMediaPreloadMixin } from '../preload';
|
||||
|
||||
function createEngine(): Hls {
|
||||
const listeners = new Map<string, Set<(...args: any[]) => void>>();
|
||||
@@ -34,9 +34,9 @@ class FakeHost implements HlsEngineHost {
|
||||
}
|
||||
}
|
||||
|
||||
const PreloadHost = HlsMediaPreloadMixin(FakeHost);
|
||||
const PreloadHost = HlsJsMediaPreloadMixin(FakeHost);
|
||||
|
||||
describe('HlsMediaPreloadMixin', () => {
|
||||
describe('HlsJsMediaPreloadMixin', () => {
|
||||
it('defaults preload to metadata', () => {
|
||||
const host = new PreloadHost(null);
|
||||
expect(host.preload).toBe('metadata');
|
||||
|
||||
@@ -18,8 +18,8 @@ interface HlsEngineHost {
|
||||
* forwards cues into them. It also syncs user track-mode changes back to
|
||||
* hls.js via `engine.subtitleTrack`.
|
||||
*/
|
||||
export function HlsMediaTextTracksMixin<Base extends Constructor<HlsEngineHost>>(BaseClass: Base) {
|
||||
class HlsMediaTextTracks extends (BaseClass as Constructor<HlsEngineHost>) {
|
||||
export function HlsJsMediaTextTracksMixin<Base extends Constructor<HlsEngineHost>>(BaseClass: Base) {
|
||||
class HlsJsMediaTextTracks extends (BaseClass as Constructor<HlsEngineHost>) {
|
||||
#disconnect: AbortController | null = null;
|
||||
|
||||
constructor(...args: any[]) {
|
||||
@@ -145,7 +145,7 @@ export function HlsMediaTextTracksMixin<Base extends Constructor<HlsEngineHost>>
|
||||
}
|
||||
}
|
||||
|
||||
return HlsMediaTextTracks as unknown as Base;
|
||||
return HlsJsMediaTextTracks as unknown as Base;
|
||||
}
|
||||
|
||||
function addTextTrack(
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Constructor } from '@videojs/utils/types';
|
||||
|
||||
import { MediaError } from '../../../core/media/media-error';
|
||||
|
||||
export interface NativeMediaHost extends EventTarget {
|
||||
readonly target: HTMLMediaElement | null;
|
||||
attach(target: HTMLMediaElement): void;
|
||||
detach(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export function NativeHlsMediaErrorsMixin<Base extends Constructor<NativeMediaHost>>(BaseClass: Base) {
|
||||
class NativeHlsMediaErrors extends (BaseClass as Constructor<NativeMediaHost>) {
|
||||
#disconnect: AbortController | null = null;
|
||||
#error: MediaError | null = null;
|
||||
|
||||
get error(): MediaError | null {
|
||||
return this.#error;
|
||||
}
|
||||
|
||||
attach(target: HTMLMediaElement): void {
|
||||
super.attach(target);
|
||||
this.#init(target);
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.#destroy();
|
||||
super.detach();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.#destroy();
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
#destroy(): void {
|
||||
this.#disconnect?.abort();
|
||||
this.#disconnect = null;
|
||||
this.#error = null;
|
||||
}
|
||||
|
||||
#init(target: HTMLMediaElement): void {
|
||||
this.#destroy();
|
||||
this.#disconnect = new AbortController();
|
||||
|
||||
const signal = this.#disconnect.signal;
|
||||
|
||||
target.addEventListener(
|
||||
'error',
|
||||
(event) => {
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
const native = target.error;
|
||||
if (!native) return;
|
||||
|
||||
const error = new MediaError(native.message, native.code, true);
|
||||
this.#error = error;
|
||||
|
||||
this.dispatchEvent(new ErrorEvent('error', { error, message: error.message }));
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
|
||||
target.addEventListener(
|
||||
'emptied',
|
||||
() => {
|
||||
this.#error = null;
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NativeHlsMediaErrors as unknown as Base & Constructor<{ readonly error: MediaError | null }>;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { DelegateMixin } from '../../../core/media/delegate';
|
||||
import { type Delegate, DelegateMixin } from '../../../core/media/delegate';
|
||||
import { CustomVideoElement } from '../custom-media-element';
|
||||
import { VideoProxy } from '../proxy';
|
||||
import { NativeHlsMediaErrorsMixin } from './errors';
|
||||
|
||||
export type PreloadType = '' | 'none' | 'metadata' | 'auto';
|
||||
|
||||
export class NativeHlsMediaDelegate extends EventTarget {
|
||||
class NativeHlsMediaDelegateBase extends EventTarget implements Delegate {
|
||||
#target: HTMLMediaElement | null = null;
|
||||
#src: string = '';
|
||||
#preload: PreloadType = 'metadata';
|
||||
@@ -17,10 +18,6 @@ export class NativeHlsMediaDelegate extends EventTarget {
|
||||
return null;
|
||||
}
|
||||
|
||||
get error() {
|
||||
return this.target?.error ?? null;
|
||||
}
|
||||
|
||||
get src() {
|
||||
return this.#src;
|
||||
}
|
||||
@@ -66,6 +63,8 @@ export class NativeHlsMediaDelegate extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
export class NativeHlsMediaDelegate extends NativeHlsMediaErrorsMixin(NativeHlsMediaDelegateBase) {}
|
||||
|
||||
export class NativeHlsCustomMedia extends DelegateMixin(CustomVideoElement, NativeHlsMediaDelegate) {}
|
||||
|
||||
export class NativeHlsMedia extends DelegateMixin(VideoProxy, NativeHlsMediaDelegate) {}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MediaError } from '../../../../core/media/media-error';
|
||||
import { NativeHlsMediaErrorsMixin, type NativeMediaHost } from '../errors';
|
||||
|
||||
class FakeHost extends EventTarget implements NativeMediaHost {
|
||||
target: HTMLMediaElement | null = null;
|
||||
|
||||
attach(target: HTMLMediaElement): void {
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.target = null;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.target = null;
|
||||
}
|
||||
}
|
||||
|
||||
const NativeHlsMediaErrors = NativeHlsMediaErrorsMixin(FakeHost);
|
||||
|
||||
function setup() {
|
||||
const host = new NativeHlsMediaErrors();
|
||||
const video = document.createElement('video');
|
||||
host.attach(video);
|
||||
return { host, video };
|
||||
}
|
||||
|
||||
function fireNativeError(video: HTMLVideoElement, code: number, message = '') {
|
||||
Object.defineProperty(video, 'error', {
|
||||
value: { code, message },
|
||||
configurable: true,
|
||||
});
|
||||
video.dispatchEvent(new Event('error'));
|
||||
}
|
||||
|
||||
describe('NativeHlsMediaErrorsMixin', () => {
|
||||
it('dispatches an error event with a MediaError for native errors', () => {
|
||||
const { host, video } = setup();
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('error', handler);
|
||||
|
||||
fireNativeError(video, MediaError.MEDIA_ERR_NETWORK, '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.message).toBe('network failure');
|
||||
});
|
||||
|
||||
it('uses default message when native error has no message', () => {
|
||||
const { host, video } = setup();
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('error', handler);
|
||||
|
||||
fireNativeError(video, MediaError.MEDIA_ERR_DECODE);
|
||||
|
||||
const event = handler.mock.calls[0]![0] as ErrorEvent;
|
||||
expect(event.error.code).toBe(MediaError.MEDIA_ERR_DECODE);
|
||||
expect(event.error.message).toBe(MediaError.defaultMessages[MediaError.MEDIA_ERR_DECODE]);
|
||||
});
|
||||
|
||||
it('exposes the error via the error getter', () => {
|
||||
const { host, video } = setup();
|
||||
|
||||
expect(host.error).toBeNull();
|
||||
|
||||
fireNativeError(video, MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED, 'unsupported');
|
||||
|
||||
expect(host.error).toBeInstanceOf(MediaError);
|
||||
expect(host.error!.code).toBe(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
|
||||
});
|
||||
|
||||
it('stops propagation of the native error event', () => {
|
||||
const { video } = setup();
|
||||
|
||||
const nativeHandler = vi.fn();
|
||||
video.addEventListener('error', nativeHandler);
|
||||
|
||||
fireNativeError(video, MediaError.MEDIA_ERR_NETWORK, 'network failure');
|
||||
|
||||
expect(nativeHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps MEDIA_ERR_ABORTED correctly', () => {
|
||||
const { host, video } = setup();
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('error', handler);
|
||||
|
||||
fireNativeError(video, MediaError.MEDIA_ERR_ABORTED);
|
||||
|
||||
const event = handler.mock.calls[0]![0] as ErrorEvent;
|
||||
expect(event.error.code).toBe(MediaError.MEDIA_ERR_ABORTED);
|
||||
});
|
||||
|
||||
it('ignores error events when target.error is null', () => {
|
||||
const { host, video } = setup();
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('error', handler);
|
||||
|
||||
video.dispatchEvent(new Event('error'));
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(host.error).toBeNull();
|
||||
});
|
||||
|
||||
it('stops listening after detach', () => {
|
||||
const { host, video } = setup();
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('error', handler);
|
||||
|
||||
host.detach();
|
||||
|
||||
fireNativeError(video, MediaError.MEDIA_ERR_NETWORK, 'after detach');
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets error after detach', () => {
|
||||
const { host, video } = setup();
|
||||
|
||||
fireNativeError(video, MediaError.MEDIA_ERR_NETWORK, 'failure');
|
||||
|
||||
expect(host.error).not.toBeNull();
|
||||
|
||||
host.detach();
|
||||
|
||||
expect(host.error).toBeNull();
|
||||
});
|
||||
|
||||
it('stops listening after destroy', () => {
|
||||
const { host, video } = setup();
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('error', handler);
|
||||
|
||||
host.destroy();
|
||||
|
||||
fireNativeError(video, MediaError.MEDIA_ERR_NETWORK, 'after destroy');
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets error after destroy', () => {
|
||||
const { host, video } = setup();
|
||||
|
||||
fireNativeError(video, MediaError.MEDIA_ERR_DECODE, 'failure');
|
||||
|
||||
expect(host.error).not.toBeNull();
|
||||
|
||||
host.destroy();
|
||||
|
||||
expect(host.error).toBeNull();
|
||||
});
|
||||
|
||||
it('clears stale error on source change (emptied event)', () => {
|
||||
const { host, video } = setup();
|
||||
|
||||
fireNativeError(video, MediaError.MEDIA_ERR_NETWORK, 'failure');
|
||||
expect(host.error).not.toBeNull();
|
||||
|
||||
video.dispatchEvent(new Event('emptied'));
|
||||
|
||||
expect(host.error).toBeNull();
|
||||
});
|
||||
|
||||
it('re-initializes on re-attach', () => {
|
||||
const { host } = setup();
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('error', handler);
|
||||
|
||||
host.detach();
|
||||
|
||||
const video2 = document.createElement('video');
|
||||
host.attach(video2);
|
||||
|
||||
fireNativeError(video2, MediaError.MEDIA_ERR_NETWORK, 'new target');
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
|
||||
const event = handler.mock.calls[0]![0] as ErrorEvent;
|
||||
expect(event.error.code).toBe(MediaError.MEDIA_ERR_NETWORK);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MediaError } from '../../../../core/media/media-error';
|
||||
import { NativeHlsCustomMedia } from '../index';
|
||||
|
||||
let counter = 0;
|
||||
|
||||
function defineElement(): string {
|
||||
const tag = `test-nhls-cm-${counter++}`;
|
||||
customElements.define(tag, class extends (NativeHlsCustomMedia as unknown as typeof HTMLElement) {});
|
||||
return tag;
|
||||
}
|
||||
|
||||
function fireNativeError(video: HTMLVideoElement, code: number, message = '') {
|
||||
Object.defineProperty(video, 'error', {
|
||||
value: { code, message },
|
||||
configurable: true,
|
||||
});
|
||||
video.dispatchEvent(new Event('error'));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('NativeHlsCustomMedia', () => {
|
||||
it('dispatches only the enriched ErrorEvent when a native error fires', () => {
|
||||
const tag = defineElement();
|
||||
const el = document.createElement(tag);
|
||||
document.body.appendChild(el);
|
||||
|
||||
const video = el.shadowRoot!.querySelector('video')! as HTMLVideoElement;
|
||||
(el as any).attach(video);
|
||||
|
||||
const handler = vi.fn();
|
||||
el.addEventListener('error', handler);
|
||||
|
||||
fireNativeError(video, MediaError.MEDIA_ERR_NETWORK, 'network failure');
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
|
||||
const event = handler.mock.calls[0]![0] as ErrorEvent;
|
||||
expect(event).toBeInstanceOf(ErrorEvent);
|
||||
expect(event.error).toBeInstanceOf(MediaError);
|
||||
expect(event.error.code).toBe(MediaError.MEDIA_ERR_NETWORK);
|
||||
expect(event.error.message).toBe('network failure');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user