feat: add native hls media + refactor (#1154)

This commit is contained in:
Wesley Luyten
2026-03-30 20:13:19 -05:00
committed by GitHub
parent 0433722ee4
commit 1b2afc6e41
24 changed files with 552 additions and 200 deletions
+1
View File
@@ -33,6 +33,7 @@
"link:aliases": "node build/scripts/link-aliases.mjs",
"size": "node .github/scripts/bundle-size.js | node .github/scripts/bundle-size-report.js",
"test": "turbo run test",
"test:packages": "turbo run test --filter='./packages/*'",
"format:astro": "prettier --write 'site/src/**/*.astro'",
"typecheck": "tsc --build",
"changelog": "git-cliff --config cliff.toml --output CHANGELOG.md"
+3 -1
View File
@@ -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;
+5 -3
View File
@@ -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;
}
}
+54
View File
@@ -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)) {}
+121 -110
View File
@@ -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) {}
+34 -39
View File
@@ -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();
});
});
+13 -14
View File
@@ -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());
}
}
+14 -7
View File
@@ -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) {}
+1
View File
@@ -14,6 +14,7 @@ const createConfig = (mode: BuildMode): UserConfig => ({
'dom/media/hls/index': './src/dom/media/hls/index.ts',
'dom/media/custom-media-element/index': './src/dom/media/custom-media-element/index.ts',
'dom/media/mux/index': './src/dom/media/mux/index.ts',
'dom/media/native-hls/index': './src/dom/media/native-hls/index.ts',
'dom/media/simple-hls/index': './src/dom/media/simple-hls/index.ts',
},
platform: 'neutral',
@@ -0,0 +1,14 @@
import { NativeHlsVideo } from '../../media/native-hls-video';
import { safeDefine } from '../safe-define';
export class NativeHlsVideoElement extends NativeHlsVideo {
static readonly tagName = 'native-hls-video';
}
safeDefine(NativeHlsVideoElement);
declare global {
interface HTMLElementTagNameMap {
[NativeHlsVideoElement.tagName]: NativeHlsVideoElement;
}
}
-1
View File
@@ -1,5 +1,4 @@
// Core
export type { Delegate } from '@videojs/core';
export { DelegateMixin } from '@videojs/core';
export * from '@videojs/core/dom';
@@ -0,0 +1,18 @@
import { NativeHlsCustomMedia, NativeHlsMediaDelegate } from '@videojs/core/dom/media/native-hls';
import { MediaAttachMixin } from '../../store/media-attach-mixin';
import { MediaPropsMixin } from '../../utils/media-props-mixin';
export class NativeHlsVideo extends MediaPropsMixin(MediaAttachMixin(NativeHlsCustomMedia), NativeHlsMediaDelegate) {
constructor() {
super();
this.attach(this.target);
}
disconnectedCallback(): void {
super.disconnectedCallback?.();
if (!this.hasAttribute('keep-alive')) {
this.destroy();
}
}
}
@@ -10,6 +10,7 @@ function buildAttrPropMap(DelegateClass: AnyClass): Map<string, string> {
const map = new Map<string, string>();
for (let proto = DelegateClass.prototype; proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
for (const key of Object.getOwnPropertyNames(proto)) {
if (key.startsWith('_')) continue;
const desc = Object.getOwnPropertyDescriptor(proto, key);
if (desc?.set) map.set(camelToKebab(key), key);
}
-1
View File
@@ -1,7 +1,6 @@
'use client';
// Core
export type { Delegate } from '@videojs/core';
export { DelegateMixin } from '@videojs/core';
export * from '@videojs/core/dom';
@@ -0,0 +1,25 @@
import type { InferDelegateProps } from '@videojs/core';
import { NativeHlsMedia, NativeHlsMediaDelegate } from '@videojs/core/dom/media/native-hls';
import type { PropsWithChildren, VideoHTMLAttributes } from 'react';
import { forwardRef } from 'react';
import { attachMediaElement } from '../../utils/attach-media-element';
import { mediaProps } from '../../utils/media-props';
import { useComposedRefs } from '../../utils/use-composed-refs';
import { useMediaInstance } from '../../utils/use-media-instance';
export type NativeHlsVideoProps = PropsWithChildren<VideoHTMLAttributes<HTMLVideoElement>> &
InferDelegateProps<typeof NativeHlsMediaDelegate>;
export const NativeHlsVideo = forwardRef<HTMLVideoElement, NativeHlsVideoProps>(({ children, ...props }, ref) => {
const mediaApi = useMediaInstance(NativeHlsMedia);
const composedRef = useComposedRefs(attachMediaElement(mediaApi), ref);
return (
<video ref={composedRef} {...mediaProps(mediaApi, NativeHlsMediaDelegate, props)}>
{children}
</video>
);
});
export default NativeHlsVideo;
+1
View File
@@ -4,6 +4,7 @@ export const STYLINGS = ['css', 'tailwind'] as const;
export const PRESETS = [
'video',
'hls-video',
'native-hls-video',
'mux-video',
'simple-hls-video',
'dash-video',
+1
View File
@@ -34,6 +34,7 @@ const PLATFORM_LABELS: Record<Platform, string> = {
const PRESET_LABELS: Record<Preset, string> = {
video: 'Video',
'hls-video': 'HLS Video',
'native-hls-video': 'Native HLS Video',
'mux-video': 'Mux Video',
'simple-hls-video': 'Simple HLS Video',
'dash-video': 'DASH Video',
+2 -23
View File
@@ -1,29 +1,8 @@
export { shallowEqual } from '@videojs/utils/object';
export interface Selector<State, Result> {
(state: State): Result;
displayName?: string | undefined;
}
export type Comparator<T> = (a: T, b: T) => boolean;
const hasOwn = Object.prototype.hasOwnProperty;
export function shallowEqual<T>(a: T, b: T): boolean {
if (Object.is(a, b)) return true;
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
return false;
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!hasOwn.call(b, key) || !Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
return false;
}
}
return true;
}
+1
View File
@@ -1,2 +1,3 @@
export { defaults } from './defaults';
export { pick } from './pick';
export { shallowEqual } from './shallow-equal';
@@ -0,0 +1,22 @@
const hasOwn = Object.prototype.hasOwnProperty;
export function shallowEqual<T>(a: T, b: T): boolean {
if (Object.is(a, b)) return true;
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
return false;
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!hasOwn.call(b, key) || !Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
return false;
}
}
return true;
}