mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat: add native hls media + refactor (#1154)
This commit is contained in:
@@ -18,8 +18,10 @@ 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>, SettableKeys<InstanceType<D>>>
|
||||
Pick<InstanceType<D>, ExcludeInternal<SettableKeys<InstanceType<D>>>>
|
||||
>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Constructor } from '@videojs/utils/types';
|
||||
|
||||
export function defineClassPropHooks<T extends Constructor<any>>(Class: T, BaseClassProto: PropertyDescriptorMap) {
|
||||
for (const prop of Object.getOwnPropertyNames(BaseClassProto)) {
|
||||
if (prop in Class.prototype) continue;
|
||||
if (prop in Class.prototype || prop.startsWith('_')) continue;
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(BaseClassProto, prop);
|
||||
if (!descriptor) continue;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import * as dashjs from 'dashjs';
|
||||
|
||||
import { type Delegate, DelegateMixin } from '../../../core/media/delegate';
|
||||
import { DelegateMixin } from '../../../core/media/delegate';
|
||||
import { CustomVideoElement } from '../custom-media-element';
|
||||
import { VideoProxy } from '../proxy';
|
||||
|
||||
export class DashMediaDelegate implements Delegate {
|
||||
export class DashMediaDelegate {
|
||||
#engine: dashjs.MediaPlayerClass;
|
||||
#src: string = '';
|
||||
|
||||
constructor() {
|
||||
this.#engine = dashjs.MediaPlayer().create();
|
||||
@@ -30,11 +31,12 @@ export class DashMediaDelegate implements Delegate {
|
||||
}
|
||||
|
||||
set src(src: string) {
|
||||
this.#src = src;
|
||||
this.#engine.attachSource(src);
|
||||
}
|
||||
|
||||
get src(): string {
|
||||
return (this.#engine.getSource() as string) ?? '';
|
||||
return this.#src;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Hls, { type HlsConfig } from 'hls.js';
|
||||
import { HlsMediaPreloadMixin } from './preload';
|
||||
import { HlsMediaTextTracksMixin } from './text-tracks';
|
||||
|
||||
export const defaultHlsConfig: Partial<HlsConfig> = {
|
||||
backBufferLength: 30,
|
||||
renderTextTracksNatively: false,
|
||||
liveDurationInfinity: true,
|
||||
capLevelToPlayerSize: true,
|
||||
capLevelOnFPSDrop: true,
|
||||
autoStartLoad: false,
|
||||
};
|
||||
|
||||
class HlsJsMediaDelegateBase {
|
||||
#engine: Hls | null = null;
|
||||
|
||||
constructor(params: { config: Partial<HlsConfig> }) {
|
||||
this.#engine = new Hls({
|
||||
...defaultHlsConfig,
|
||||
...params.config,
|
||||
});
|
||||
}
|
||||
|
||||
get target() {
|
||||
return this.#engine?.media ?? null;
|
||||
}
|
||||
|
||||
get engine() {
|
||||
return this.#engine;
|
||||
}
|
||||
|
||||
get src() {
|
||||
return this.#engine?.url ?? '';
|
||||
}
|
||||
|
||||
set src(src: string) {
|
||||
this.#engine?.loadSource(src);
|
||||
}
|
||||
|
||||
attach(target: HTMLMediaElement) {
|
||||
this.#engine?.attachMedia(target);
|
||||
}
|
||||
|
||||
detach() {
|
||||
this.#engine?.detachMedia();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.#engine?.destroy();
|
||||
this.#engine = null;
|
||||
}
|
||||
}
|
||||
|
||||
export class HlsJsMediaDelegate extends HlsMediaPreloadMixin(HlsMediaTextTracksMixin(HlsJsMediaDelegateBase)) {}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { shallowEqual } from '@videojs/utils/object';
|
||||
import Hls from 'hls.js';
|
||||
import { type Delegate, DelegateMixin } from '../../../core/media/delegate';
|
||||
import { DelegateMixin } from '../../../core/media/delegate';
|
||||
import { CustomVideoElement } from '../custom-media-element';
|
||||
import { NativeHlsMediaDelegate } from '../native-hls';
|
||||
import { VideoProxy } from '../proxy';
|
||||
import { HlsMediaPreloadMixin } from './preload';
|
||||
import { HlsMediaTextTracksMixin } from './text-tracks';
|
||||
import { HlsJsMediaDelegate } from './hlsjs';
|
||||
|
||||
export type PreloadType = '' | 'none' | 'metadata' | 'auto';
|
||||
|
||||
export { Hls };
|
||||
|
||||
@@ -20,108 +23,123 @@ export const SourceTypes = {
|
||||
MP4: 'video/mp4',
|
||||
};
|
||||
|
||||
const defaultConfig = {
|
||||
backBufferLength: 30,
|
||||
renderTextTracksNatively: false,
|
||||
liveDurationInfinity: true,
|
||||
capLevelToPlayerSize: true,
|
||||
capLevelOnFPSDrop: true,
|
||||
// Disable auto quality level/fragment loading (preload).
|
||||
autoStartLoad: false,
|
||||
};
|
||||
|
||||
export class HlsMediaDelegateBase implements Delegate {
|
||||
export class HlsMediaDelegate {
|
||||
#target: HTMLMediaElement | null = null;
|
||||
#engine: Hls | null = null;
|
||||
#loadRequested?: Promise<void> | null;
|
||||
#delegate: HlsJsMediaDelegate | NativeHlsMediaDelegate | null = null;
|
||||
#src: string = '';
|
||||
#debug: boolean = false;
|
||||
#type: SourceType | undefined;
|
||||
#preferPlayback: PlaybackType | undefined = 'mse';
|
||||
#config: Record<string, any> = {};
|
||||
#debug: boolean = false;
|
||||
#preload: PreloadType = 'metadata';
|
||||
#loadRequested?: Promise<void> | null;
|
||||
#prevEngineProps?: Record<string, any> | null;
|
||||
|
||||
constructor() {
|
||||
this.initEngine();
|
||||
get target() {
|
||||
return this.#target;
|
||||
}
|
||||
|
||||
destroyEngine(): void {
|
||||
this.#engine?.destroy();
|
||||
this.#engine = null;
|
||||
get engine() {
|
||||
return this.#delegate?.engine ?? null;
|
||||
}
|
||||
|
||||
initEngine(): void {
|
||||
if (this.#engine) this.destroyEngine();
|
||||
|
||||
if (!Hls.isSupported() || this.type !== SourceTypes.M3U8 || this.#preferPlayback === PlaybackTypes.NATIVE) {
|
||||
if (this.#src) this.#requestLoad();
|
||||
return;
|
||||
}
|
||||
|
||||
this.#engine = new Hls({
|
||||
...defaultConfig,
|
||||
debug: this.#debug,
|
||||
});
|
||||
|
||||
if (this.#target) {
|
||||
this.#engine.attachMedia(this.#target as HTMLMediaElement);
|
||||
}
|
||||
|
||||
if (this.#src) this.#requestLoad();
|
||||
get src() {
|
||||
return this.#src;
|
||||
}
|
||||
|
||||
/** The target element, or `null` when not attached. */
|
||||
get target(): EventTarget | null | undefined {
|
||||
return this.#target ?? null;
|
||||
}
|
||||
|
||||
/** The underlying hls.js instance, or `null` when using native playback. */
|
||||
get engine(): Hls | null {
|
||||
return this.#engine;
|
||||
}
|
||||
|
||||
/** Explicit source type. When unset, inferred from the source URL extension. */
|
||||
get type(): SourceType | undefined {
|
||||
return this.#type ?? inferSourceType(this.#src);
|
||||
}
|
||||
|
||||
set type(value: SourceType | undefined) {
|
||||
if (this.#type === value) return;
|
||||
this.#type = value;
|
||||
this.initEngine();
|
||||
}
|
||||
|
||||
/** Enable hls.js debug logging. Re-initializes the engine when changed. */
|
||||
get debug(): boolean {
|
||||
return this.#debug;
|
||||
}
|
||||
|
||||
set debug(value: boolean) {
|
||||
if (this.#debug === value) return;
|
||||
this.#debug = value;
|
||||
this.initEngine();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to prefer `'mse'` (hls.js) or `'native'` (browser-built-in) HLS
|
||||
* playback. Changing this re-initializes the engine.
|
||||
*/
|
||||
get preferPlayback(): PlaybackType | undefined {
|
||||
return this.#preferPlayback;
|
||||
}
|
||||
|
||||
set preferPlayback(value: PlaybackType | undefined) {
|
||||
if (this.#preferPlayback === value) return;
|
||||
this.#preferPlayback = value;
|
||||
this.initEngine();
|
||||
}
|
||||
|
||||
/** The HLS source URL to load. */
|
||||
set src(src: string) {
|
||||
this.#src = src;
|
||||
this.#requestLoad();
|
||||
}
|
||||
|
||||
get src(): string {
|
||||
return this.#src;
|
||||
/** Explicit source type. When unset, inferred from the source URL extension. */
|
||||
get type(): SourceType | undefined {
|
||||
return this.#type ?? inferSourceType(this.src);
|
||||
}
|
||||
|
||||
set type(value: SourceType | undefined) {
|
||||
this.#type = value;
|
||||
this.#requestLoad();
|
||||
}
|
||||
|
||||
/** Whether to prefer `'mse'` (hls.js) or `'native'` (browser-built-in) HLS. */
|
||||
get preferPlayback(): PlaybackType | undefined {
|
||||
return this.#preferPlayback;
|
||||
}
|
||||
|
||||
set preferPlayback(value: PlaybackType | undefined) {
|
||||
this.#preferPlayback = value;
|
||||
this.#requestLoad();
|
||||
}
|
||||
|
||||
get config() {
|
||||
return this.#config;
|
||||
}
|
||||
|
||||
set config(config: Record<string, any>) {
|
||||
this.#config = config;
|
||||
this.#requestLoad();
|
||||
}
|
||||
|
||||
get debug() {
|
||||
return this.#debug;
|
||||
}
|
||||
|
||||
set debug(debug: boolean) {
|
||||
this.#debug = debug;
|
||||
this.#requestLoad();
|
||||
}
|
||||
|
||||
get preload() {
|
||||
return this.#preload;
|
||||
}
|
||||
|
||||
set preload(value: PreloadType) {
|
||||
this.#preload = value;
|
||||
if (this.#delegate) {
|
||||
this.#delegate.preload = value;
|
||||
}
|
||||
}
|
||||
|
||||
attach(target: HTMLMediaElement) {
|
||||
this.#target = target;
|
||||
this.#delegate?.attach(target);
|
||||
}
|
||||
|
||||
detach() {
|
||||
this.#target = null;
|
||||
this.#delegate?.detach();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.#engineDestroy();
|
||||
this.detach();
|
||||
}
|
||||
|
||||
load() {
|
||||
this.#loadRequested = null;
|
||||
|
||||
if (this.#shouldEngineUpdate(this.#engineProps())) {
|
||||
this.#engineDestroy();
|
||||
this.#prevEngineProps = this.#engineProps();
|
||||
|
||||
const useMse =
|
||||
Hls.isSupported() && this.type === SourceTypes.M3U8 && this.preferPlayback !== PlaybackTypes.NATIVE;
|
||||
|
||||
this.#delegate = useMse
|
||||
? new HlsJsMediaDelegate({ config: { ...this.config, debug: this.debug } })
|
||||
: new NativeHlsMediaDelegate();
|
||||
|
||||
if (this.target) {
|
||||
this.#delegate.attach(this.target);
|
||||
}
|
||||
|
||||
this.#delegate.preload = this.preload;
|
||||
}
|
||||
|
||||
if (this.#delegate) {
|
||||
this.#delegate.src = this.#src;
|
||||
}
|
||||
}
|
||||
|
||||
async #requestLoad() {
|
||||
@@ -131,27 +149,24 @@ export class HlsMediaDelegateBase implements Delegate {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
if (this.#engine) {
|
||||
this.#engine.loadSource(this.#src);
|
||||
} else if (this.#target) {
|
||||
(this.#target as HTMLMediaElement).src = this.#src;
|
||||
}
|
||||
#shouldEngineUpdate(nextEngineProps: Record<string, any>) {
|
||||
return !shallowEqual(this.#prevEngineProps, nextEngineProps);
|
||||
}
|
||||
|
||||
attach(target: HTMLMediaElement): void {
|
||||
this.#target = target;
|
||||
this.#engine?.attachMedia(target);
|
||||
#engineProps() {
|
||||
return {
|
||||
config: this.config,
|
||||
debug: this.debug,
|
||||
preferPlayback: this.preferPlayback,
|
||||
type: this.type,
|
||||
};
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.#engine?.detachMedia();
|
||||
this.#target = null;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyEngine();
|
||||
this.#target = null;
|
||||
#engineDestroy(): void {
|
||||
this.#delegate?.destroy();
|
||||
this.#delegate = null;
|
||||
this.#prevEngineProps = null;
|
||||
this.#loadRequested = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,10 +176,6 @@ function inferSourceType(src: string): SourceType {
|
||||
return SourceTypes.M3U8;
|
||||
}
|
||||
|
||||
export const HlsMediaDelegate = HlsMediaTextTracksMixin(HlsMediaPreloadMixin(HlsMediaDelegateBase));
|
||||
|
||||
// This is used by the web component because it needs to extend HTMLElement!
|
||||
export class HlsCustomMedia extends DelegateMixin(CustomVideoElement, HlsMediaDelegate) {}
|
||||
|
||||
// This is used by the React component.
|
||||
export class HlsMedia extends DelegateMixin(VideoProxy, HlsMediaDelegate) {}
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import type { Constructor } from '@videojs/utils/types';
|
||||
import type Hls from 'hls.js';
|
||||
import Hls from 'hls.js';
|
||||
|
||||
export interface HlsEngineHost {
|
||||
readonly engine: Hls | null;
|
||||
readonly target: HTMLMediaElement | null;
|
||||
}
|
||||
|
||||
export type PreloadType = '' | 'none' | 'metadata' | 'auto';
|
||||
|
||||
interface HlsPreloadHost {
|
||||
readonly engine: Hls | null;
|
||||
readonly target: EventTarget | null | undefined;
|
||||
load?(): void;
|
||||
attach?(target: EventTarget): void;
|
||||
detach?(): void;
|
||||
destroy?(): void;
|
||||
destroyEngine?(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages HLS preload behavior by mapping the media element's `preload`
|
||||
* attribute to hls.js `startLoad` / buffer-limit configuration.
|
||||
@@ -21,49 +16,49 @@ interface HlsPreloadHost {
|
||||
* - `'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<HlsPreloadHost>>(BaseClass: Base) {
|
||||
class HlsMediaPreload extends (BaseClass as Constructor<HlsPreloadHost>) {
|
||||
#preloadAbort?: AbortController;
|
||||
export function HlsMediaPreloadMixin<Base extends Constructor<HlsEngineHost>>(BaseClass: Base) {
|
||||
class HlsMediaPreload extends (BaseClass as Constructor<HlsEngineHost>) {
|
||||
#preloadAbort: AbortController | null = null;
|
||||
#preload: PreloadType = 'metadata';
|
||||
#defaultMaxBufferLength: number | undefined;
|
||||
#defaultMaxBufferSize: number | undefined;
|
||||
|
||||
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 preload(): PreloadType {
|
||||
return (this.target as HTMLMediaElement | null)?.preload || 'metadata';
|
||||
return this.#preload;
|
||||
}
|
||||
|
||||
set preload(value: PreloadType) {
|
||||
const target = this.target as HTMLMediaElement | null;
|
||||
if (!target || target.preload === value) return;
|
||||
target.preload = value;
|
||||
this.#updatePreload();
|
||||
this.#preload = value;
|
||||
this.#init();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
super.load?.();
|
||||
this.#updatePreload();
|
||||
}
|
||||
|
||||
attach(target: EventTarget): void {
|
||||
super.attach?.(target);
|
||||
this.#updatePreload();
|
||||
}
|
||||
|
||||
destroyEngine(): void {
|
||||
#destroy(): void {
|
||||
this.#preloadAbort?.abort();
|
||||
super.destroyEngine?.();
|
||||
this.#preloadAbort = null;
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.#preloadAbort?.abort();
|
||||
super.detach?.();
|
||||
}
|
||||
|
||||
#updatePreload(): void {
|
||||
#init(): void {
|
||||
this.#preloadAbort?.abort();
|
||||
|
||||
const target = this.target as HTMLMediaElement | null;
|
||||
if (!target) return;
|
||||
|
||||
// Sync stored preload to the native element (may have been set before attach)
|
||||
if (target.preload !== this.preload) {
|
||||
target.preload = this.preload;
|
||||
}
|
||||
|
||||
const { engine } = this;
|
||||
if (!target || !engine) return;
|
||||
if (!engine) return;
|
||||
|
||||
this.#defaultMaxBufferLength ??= engine.config.maxBufferLength;
|
||||
this.#defaultMaxBufferSize ??= engine.config.maxBufferSize;
|
||||
@@ -96,5 +91,5 @@ export function HlsMediaPreloadMixin<Base extends Constructor<HlsPreloadHost>>(B
|
||||
}
|
||||
}
|
||||
|
||||
return HlsMediaPreload as unknown as Base;
|
||||
return HlsMediaPreload as unknown as Base & Constructor<{ preload: PreloadType }>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import Hls from 'hls.js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { type HlsEngineHost, HlsMediaPreloadMixin } from '../preload';
|
||||
|
||||
function createEngine(): Hls {
|
||||
const listeners = new Map<string, Set<(...args: any[]) => void>>();
|
||||
return {
|
||||
config: {
|
||||
maxBufferLength: 30,
|
||||
maxBufferSize: 60_000_000,
|
||||
},
|
||||
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);
|
||||
},
|
||||
startLoad: vi.fn(),
|
||||
media: null,
|
||||
} as unknown as Hls;
|
||||
}
|
||||
|
||||
class FakeHost implements HlsEngineHost {
|
||||
engine: Hls | null;
|
||||
target: HTMLMediaElement | null = null;
|
||||
|
||||
constructor(engine: Hls | null = null) {
|
||||
this.engine = engine;
|
||||
}
|
||||
}
|
||||
|
||||
const PreloadHost = HlsMediaPreloadMixin(FakeHost);
|
||||
|
||||
describe('HlsMediaPreloadMixin', () => {
|
||||
it('defaults preload to metadata', () => {
|
||||
const host = new PreloadHost(null);
|
||||
expect(host.preload).toBe('metadata');
|
||||
});
|
||||
|
||||
it('stores preload value even when target is null', () => {
|
||||
const engine = createEngine();
|
||||
const host = new PreloadHost(engine);
|
||||
|
||||
host.preload = 'none';
|
||||
|
||||
expect(host.preload).toBe('none');
|
||||
expect(host.target).toBeNull();
|
||||
});
|
||||
|
||||
it('syncs stored preload to native element on MEDIA_ATTACHED', () => {
|
||||
const engine = createEngine();
|
||||
const host = new PreloadHost(engine);
|
||||
|
||||
host.preload = 'none';
|
||||
expect(host.target).toBeNull();
|
||||
|
||||
const video = document.createElement('video');
|
||||
host.target = video;
|
||||
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
|
||||
|
||||
expect(video.preload).toBe('none');
|
||||
});
|
||||
|
||||
it('applies preload set before attach when MEDIA_ATTACHED fires', () => {
|
||||
const engine = createEngine();
|
||||
const host = new PreloadHost(engine);
|
||||
|
||||
host.preload = 'auto';
|
||||
|
||||
const video = document.createElement('video');
|
||||
host.target = video;
|
||||
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
|
||||
|
||||
expect(engine.startLoad).toHaveBeenCalled();
|
||||
expect(video.preload).toBe('auto');
|
||||
});
|
||||
|
||||
it('uses stored preload (not native default) for loading strategy on MEDIA_ATTACHED', () => {
|
||||
const engine = createEngine();
|
||||
const host = new PreloadHost(engine);
|
||||
|
||||
host.preload = 'none';
|
||||
|
||||
const video = document.createElement('video');
|
||||
host.target = video;
|
||||
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
|
||||
|
||||
expect(engine.startLoad).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts metadata-level load for preload=metadata', () => {
|
||||
const engine = createEngine();
|
||||
const host = new PreloadHost(engine);
|
||||
|
||||
host.preload = 'metadata';
|
||||
|
||||
const video = document.createElement('video');
|
||||
host.target = video;
|
||||
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
|
||||
|
||||
expect(engine.startLoad).toHaveBeenCalled();
|
||||
expect(engine.config.maxBufferLength).toBe(1);
|
||||
expect(engine.config.maxBufferSize).toBe(1);
|
||||
});
|
||||
|
||||
it('defers full load to play event when preload=metadata', () => {
|
||||
const engine = createEngine();
|
||||
const host = new PreloadHost(engine);
|
||||
|
||||
host.preload = 'metadata';
|
||||
|
||||
const video = document.createElement('video');
|
||||
host.target = video;
|
||||
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
|
||||
|
||||
(engine.startLoad as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
video.dispatchEvent(new Event('play'));
|
||||
|
||||
expect(engine.startLoad).toHaveBeenCalled();
|
||||
expect(engine.config.maxBufferLength).toBe(30);
|
||||
expect(engine.config.maxBufferSize).toBe(60_000_000);
|
||||
});
|
||||
|
||||
it('applies preload to native element immediately when target exists', () => {
|
||||
const engine = createEngine();
|
||||
const host = new PreloadHost(engine);
|
||||
|
||||
const video = document.createElement('video');
|
||||
host.target = video;
|
||||
|
||||
host.preload = 'auto';
|
||||
|
||||
expect(video.preload).toBe('auto');
|
||||
});
|
||||
|
||||
it('cleans up on MEDIA_DETACHED', () => {
|
||||
const engine = createEngine();
|
||||
const host = new PreloadHost(engine);
|
||||
|
||||
host.preload = 'metadata';
|
||||
|
||||
const video = document.createElement('video');
|
||||
host.target = video;
|
||||
(engine as any).emit(Hls.Events.MEDIA_ATTACHED);
|
||||
|
||||
(engine.startLoad as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
(engine as any).emit(Hls.Events.MEDIA_DETACHED);
|
||||
|
||||
video.dispatchEvent(new Event('play'));
|
||||
expect(engine.startLoad).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,7 @@ import Hls from 'hls.js';
|
||||
|
||||
interface HlsEngineHost {
|
||||
readonly engine: Hls | null;
|
||||
attach?(target: EventTarget): void;
|
||||
detach?(): void;
|
||||
readonly target: HTMLMediaElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,30 +21,30 @@ interface HlsEngineHost {
|
||||
export function HlsMediaTextTracksMixin<Base extends Constructor<HlsEngineHost>>(BaseClass: Base) {
|
||||
class HlsMediaTextTracks extends (BaseClass as Constructor<HlsEngineHost>) {
|
||||
#disconnect: AbortController | null = null;
|
||||
#target: HTMLMediaElement | null = null;
|
||||
|
||||
attach(target: EventTarget): void {
|
||||
super.attach?.(target);
|
||||
this.#target = target as HTMLMediaElement;
|
||||
this.#connect();
|
||||
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());
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
#destroy(): void {
|
||||
this.#disconnect?.abort();
|
||||
this.#disconnect = null;
|
||||
this.#target = null;
|
||||
super.detach?.();
|
||||
}
|
||||
|
||||
#connect(): void {
|
||||
#init(): void {
|
||||
this.#disconnect?.abort();
|
||||
this.#disconnect = new AbortController();
|
||||
|
||||
const { signal } = this.#disconnect;
|
||||
const { engine } = this;
|
||||
if (!engine) return;
|
||||
if (!engine || !this.target) return;
|
||||
|
||||
const media = this.#target!;
|
||||
const media = this.target;
|
||||
|
||||
const onTracksFound = (_event: string, data: NonNativeTextTracksData) => {
|
||||
this.#clearTracks();
|
||||
@@ -141,7 +140,7 @@ export function HlsMediaTextTracksMixin<Base extends Constructor<HlsEngineHost>>
|
||||
}
|
||||
|
||||
#clearTracks(): void {
|
||||
const trackEls = this.#target!.querySelectorAll('track[data-removeondestroy]');
|
||||
const trackEls = this.target?.querySelectorAll?.('track[data-removeondestroy]') ?? [];
|
||||
trackEls.forEach((trackEl) => trackEl.remove());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Mux from 'mux-embed';
|
||||
|
||||
import { type Delegate, DelegateMixin } from '../../../core/media/delegate';
|
||||
import { DelegateMixin } from '../../../core/media/delegate';
|
||||
import { CustomVideoElement } from '../custom-media-element';
|
||||
import { Hls, HlsMediaDelegate } from '../hls';
|
||||
import { VideoProxy } from '../proxy';
|
||||
@@ -9,7 +9,7 @@ import type { MuxDataSdk } from './types';
|
||||
|
||||
const MUX_VIDEO_DOMAIN = 'mux.com';
|
||||
|
||||
export class MuxMediaDelegate extends HlsMediaDelegate implements Delegate {
|
||||
export class MuxMediaDelegate extends HlsMediaDelegate {
|
||||
static PLAYER_SOFTWARE_NAME = '';
|
||||
|
||||
#playbackId: string | null = null;
|
||||
@@ -108,15 +108,22 @@ export class MuxMediaDelegate extends HlsMediaDelegate implements Delegate {
|
||||
this.#metadata = value;
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.#MuxDataSdk?.destroyMonitor(this.target as HTMLMediaElement);
|
||||
attach(target: HTMLMediaElement): void {
|
||||
super.attach(target);
|
||||
this.#initializeMuxDataSdk();
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (this.target?.mux) {
|
||||
this.target.mux.destroy();
|
||||
delete this.target.mux;
|
||||
}
|
||||
super.detach();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.#initializeMuxDataSdk();
|
||||
super.load();
|
||||
this.#initializeMuxDataSdk();
|
||||
}
|
||||
|
||||
#syncSrc(): void {
|
||||
@@ -125,7 +132,7 @@ export class MuxMediaDelegate extends HlsMediaDelegate implements Delegate {
|
||||
|
||||
#initializeMuxDataSdk(): void {
|
||||
const target = this.target as HTMLMediaElement;
|
||||
if (!this.#MuxDataSdk || !target || target.mux) return;
|
||||
if (!this.#MuxDataSdk || !target || (target.mux && !target.mux.deleted)) return;
|
||||
|
||||
const {
|
||||
debug,
|
||||
@@ -144,7 +151,7 @@ export class MuxMediaDelegate extends HlsMediaDelegate implements Delegate {
|
||||
metadata.view_session_id = view_session_id;
|
||||
metadata.video_id = video_id;
|
||||
|
||||
this.#MuxDataSdk?.monitor(this.target as HTMLMediaElement, {
|
||||
this.#MuxDataSdk?.monitor(target, {
|
||||
debug,
|
||||
...(beaconCollectionDomain ? { beaconCollectionDomain } : {}),
|
||||
...(disableCookies ? { disableCookies } : {}),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { DelegateMixin } from '../../../core/media/delegate';
|
||||
import { CustomVideoElement } from '../custom-media-element';
|
||||
import { VideoProxy } from '../proxy';
|
||||
|
||||
export type PreloadType = '' | 'none' | 'metadata' | 'auto';
|
||||
|
||||
export class NativeHlsMediaDelegate {
|
||||
#target: HTMLMediaElement | null = null;
|
||||
#src: string = '';
|
||||
#preload: PreloadType = 'metadata';
|
||||
|
||||
get target() {
|
||||
return this.#target;
|
||||
}
|
||||
|
||||
get engine() {
|
||||
return null;
|
||||
}
|
||||
|
||||
get src() {
|
||||
return this.#src;
|
||||
}
|
||||
|
||||
set src(src: string) {
|
||||
this.#src = src;
|
||||
|
||||
if (this.#target) {
|
||||
this.#target.src = src;
|
||||
}
|
||||
}
|
||||
|
||||
get preload() {
|
||||
return this.#preload ?? 'metadata';
|
||||
}
|
||||
|
||||
set preload(value: PreloadType) {
|
||||
this.#preload = value;
|
||||
|
||||
if (this.#target) {
|
||||
this.#target.preload = value;
|
||||
}
|
||||
}
|
||||
|
||||
attach(target: HTMLMediaElement) {
|
||||
this.#target = target;
|
||||
this.#target.src = this.src;
|
||||
this.#target.preload = this.preload;
|
||||
}
|
||||
|
||||
detach() {
|
||||
this.#target = null;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.#target = null;
|
||||
}
|
||||
}
|
||||
|
||||
export class NativeHlsCustomMedia extends DelegateMixin(CustomVideoElement, NativeHlsMediaDelegate) {}
|
||||
|
||||
export class NativeHlsMedia extends DelegateMixin(VideoProxy, NativeHlsMediaDelegate) {}
|
||||
Reference in New Issue
Block a user