diff --git a/package.json b/package.json index e064492a..d6a81b2e 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packages/core/src/core/media/delegate.ts b/packages/core/src/core/media/delegate.ts index e3a5d913..2ed2e9a0 100644 --- a/packages/core/src/core/media/delegate.ts +++ b/packages/core/src/core/media/delegate.ts @@ -18,8 +18,10 @@ type SettableKeys = { [K in WritableKeys]: T[K] extends (...args: any[]) => any ? never : K; }[WritableKeys]; +type ExcludeInternal = K extends `_${string}` ? never : K; + export type InferDelegateProps any> = Partial< - Pick, SettableKeys>> + Pick, ExcludeInternal>>> >; /** diff --git a/packages/core/src/core/utils/define-class-prop-hooks.ts b/packages/core/src/core/utils/define-class-prop-hooks.ts index a4bf81ab..0556b85b 100644 --- a/packages/core/src/core/utils/define-class-prop-hooks.ts +++ b/packages/core/src/core/utils/define-class-prop-hooks.ts @@ -2,7 +2,7 @@ import type { Constructor } from '@videojs/utils/types'; export function defineClassPropHooks>(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; diff --git a/packages/core/src/dom/media/dash/index.ts b/packages/core/src/dom/media/dash/index.ts index 7da7984c..eeedaceb 100644 --- a/packages/core/src/dom/media/dash/index.ts +++ b/packages/core/src/dom/media/dash/index.ts @@ -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; } } diff --git a/packages/core/src/dom/media/hls/hlsjs.ts b/packages/core/src/dom/media/hls/hlsjs.ts new file mode 100644 index 00000000..eb9cacc5 --- /dev/null +++ b/packages/core/src/dom/media/hls/hlsjs.ts @@ -0,0 +1,54 @@ +import Hls, { type HlsConfig } from 'hls.js'; +import { HlsMediaPreloadMixin } from './preload'; +import { HlsMediaTextTracksMixin } from './text-tracks'; + +export const defaultHlsConfig: Partial = { + backBufferLength: 30, + renderTextTracksNatively: false, + liveDurationInfinity: true, + capLevelToPlayerSize: true, + capLevelOnFPSDrop: true, + autoStartLoad: false, +}; + +class HlsJsMediaDelegateBase { + #engine: Hls | null = null; + + constructor(params: { config: Partial }) { + 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)) {} diff --git a/packages/core/src/dom/media/hls/index.ts b/packages/core/src/dom/media/hls/index.ts index 057498b4..906a8f66 100644 --- a/packages/core/src/dom/media/hls/index.ts +++ b/packages/core/src/dom/media/hls/index.ts @@ -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 | null; + #delegate: HlsJsMediaDelegate | NativeHlsMediaDelegate | null = null; #src: string = ''; - #debug: boolean = false; #type: SourceType | undefined; #preferPlayback: PlaybackType | undefined = 'mse'; + #config: Record = {}; + #debug: boolean = false; + #preload: PreloadType = 'metadata'; + #loadRequested?: Promise | null; + #prevEngineProps?: Record | 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) { + 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) { + 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) {} diff --git a/packages/core/src/dom/media/hls/preload.ts b/packages/core/src/dom/media/hls/preload.ts index d8d959c9..ebb59eb4 100644 --- a/packages/core/src/dom/media/hls/preload.ts +++ b/packages/core/src/dom/media/hls/preload.ts @@ -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>(BaseClass: Base) { - class HlsMediaPreload extends (BaseClass as Constructor) { - #preloadAbort?: AbortController; +export function HlsMediaPreloadMixin>(BaseClass: Base) { + class HlsMediaPreload extends (BaseClass as Constructor) { + #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>(B } } - return HlsMediaPreload as unknown as Base; + return HlsMediaPreload as unknown as Base & Constructor<{ preload: PreloadType }>; } diff --git a/packages/core/src/dom/media/hls/tests/preload.test.ts b/packages/core/src/dom/media/hls/tests/preload.test.ts new file mode 100644 index 00000000..cd9bf602 --- /dev/null +++ b/packages/core/src/dom/media/hls/tests/preload.test.ts @@ -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 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).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).mockClear(); + + (engine as any).emit(Hls.Events.MEDIA_DETACHED); + + video.dispatchEvent(new Event('play')); + expect(engine.startLoad).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/dom/media/hls/text-tracks.ts b/packages/core/src/dom/media/hls/text-tracks.ts index 16b357c2..112e5bec 100644 --- a/packages/core/src/dom/media/hls/text-tracks.ts +++ b/packages/core/src/dom/media/hls/text-tracks.ts @@ -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>(BaseClass: Base) { class HlsMediaTextTracks extends (BaseClass as Constructor) { #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> } #clearTracks(): void { - const trackEls = this.#target!.querySelectorAll('track[data-removeondestroy]'); + const trackEls = this.target?.querySelectorAll?.('track[data-removeondestroy]') ?? []; trackEls.forEach((trackEl) => trackEl.remove()); } } diff --git a/packages/core/src/dom/media/mux/index.ts b/packages/core/src/dom/media/mux/index.ts index a028e99c..a39cf298 100644 --- a/packages/core/src/dom/media/mux/index.ts +++ b/packages/core/src/dom/media/mux/index.ts @@ -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 } : {}), diff --git a/packages/core/src/dom/media/native-hls/index.ts b/packages/core/src/dom/media/native-hls/index.ts new file mode 100644 index 00000000..51801159 --- /dev/null +++ b/packages/core/src/dom/media/native-hls/index.ts @@ -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) {} diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 70161f9a..e5fd07a3 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -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', diff --git a/packages/html/src/define/media/native-hls-video.ts b/packages/html/src/define/media/native-hls-video.ts new file mode 100644 index 00000000..7d51f9d9 --- /dev/null +++ b/packages/html/src/define/media/native-hls-video.ts @@ -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; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index c3991b0b..d993b635 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -1,5 +1,4 @@ // Core -export type { Delegate } from '@videojs/core'; export { DelegateMixin } from '@videojs/core'; export * from '@videojs/core/dom'; diff --git a/packages/html/src/media/native-hls-video/index.ts b/packages/html/src/media/native-hls-video/index.ts new file mode 100644 index 00000000..a732f7f3 --- /dev/null +++ b/packages/html/src/media/native-hls-video/index.ts @@ -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(); + } + } +} diff --git a/packages/html/src/utils/media-props-mixin.ts b/packages/html/src/utils/media-props-mixin.ts index 0815c5be..97ad7cd6 100644 --- a/packages/html/src/utils/media-props-mixin.ts +++ b/packages/html/src/utils/media-props-mixin.ts @@ -10,6 +10,7 @@ function buildAttrPropMap(DelegateClass: AnyClass): Map { const map = new Map(); 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); } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index f951885a..93f29c53 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,7 +1,6 @@ 'use client'; // Core -export type { Delegate } from '@videojs/core'; export { DelegateMixin } from '@videojs/core'; export * from '@videojs/core/dom'; diff --git a/packages/react/src/media/native-hls-video/index.tsx b/packages/react/src/media/native-hls-video/index.tsx new file mode 100644 index 00000000..2dad3af8 --- /dev/null +++ b/packages/react/src/media/native-hls-video/index.tsx @@ -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> & + InferDelegateProps; + +export const NativeHlsVideo = forwardRef(({ children, ...props }, ref) => { + const mediaApi = useMediaInstance(NativeHlsMedia); + + const composedRef = useComposedRefs(attachMediaElement(mediaApi), ref); + + return ( + + ); +}); + +export default NativeHlsVideo; diff --git a/packages/sandbox/app/constants.ts b/packages/sandbox/app/constants.ts index 59f1fd3d..514e79f0 100644 --- a/packages/sandbox/app/constants.ts +++ b/packages/sandbox/app/constants.ts @@ -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', diff --git a/packages/sandbox/app/shell/navbar.tsx b/packages/sandbox/app/shell/navbar.tsx index f3f82c74..db2c626a 100644 --- a/packages/sandbox/app/shell/navbar.tsx +++ b/packages/sandbox/app/shell/navbar.tsx @@ -34,6 +34,7 @@ const PLATFORM_LABELS: Record = { const PRESET_LABELS: Record = { 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', diff --git a/packages/store/src/core/shallow-equal.ts b/packages/store/src/core/shallow-equal.ts index b22ee0c1..5cc04fe8 100644 --- a/packages/store/src/core/shallow-equal.ts +++ b/packages/store/src/core/shallow-equal.ts @@ -1,29 +1,8 @@ +export { shallowEqual } from '@videojs/utils/object'; + export interface Selector { (state: State): Result; displayName?: string | undefined; } export type Comparator = (a: T, b: T) => boolean; - -const hasOwn = Object.prototype.hasOwnProperty; - -export function shallowEqual(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)[key], (b as Record)[key])) { - return false; - } - } - - return true; -} diff --git a/packages/utils/src/object/index.ts b/packages/utils/src/object/index.ts index 594335fc..fd84498e 100644 --- a/packages/utils/src/object/index.ts +++ b/packages/utils/src/object/index.ts @@ -1,2 +1,3 @@ export { defaults } from './defaults'; export { pick } from './pick'; +export { shallowEqual } from './shallow-equal'; diff --git a/packages/utils/src/object/shallow-equal.ts b/packages/utils/src/object/shallow-equal.ts new file mode 100644 index 00000000..f248d2bf --- /dev/null +++ b/packages/utils/src/object/shallow-equal.ts @@ -0,0 +1,22 @@ +const hasOwn = Object.prototype.hasOwnProperty; + +export function shallowEqual(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)[key], (b as Record)[key])) { + return false; + } + } + + return true; +} diff --git a/packages/store/src/core/tests/shallow-equal.test.ts b/packages/utils/src/object/tests/shallow-equal.test.ts similarity index 100% rename from packages/store/src/core/tests/shallow-equal.test.ts rename to packages/utils/src/object/tests/shallow-equal.test.ts