feat: add Mux video component (#1036)

This commit is contained in:
Wesley Luyten
2026-03-24 18:19:50 -05:00
committed by GitHub
parent 833210a270
commit 271a8c8502
45 changed files with 941 additions and 182 deletions
+15
View File
@@ -7,6 +7,21 @@ export interface Delegate {
detach?(): void;
}
// Detects readonly vs writable properties via conditional type identity check.
type IfEquals<X, Y, A, B> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? A : B;
type WritableKeys<T> = {
[K in keyof T]-?: IfEquals<{ [Q in K]: T[K] }, { -readonly [Q in K]: T[K] }, K, never>;
}[keyof T];
type SettableKeys<T> = {
[K in WritableKeys<T>]: T[K] extends (...args: any[]) => any ? never : K;
}[WritableKeys<T>];
export type InferDelegateProps<D extends abstract new (...args: any[]) => any> = Partial<
Pick<InstanceType<D>, SettableKeys<InstanceType<D>>>
>;
/**
* Mixin that intercepts `get`, `set`, and `call` to delegate property access
* and method calls to an instance of `DelegateClass` before falling through
+5 -8
View File
@@ -1,10 +1,10 @@
import * as dashjs from 'dashjs';
import { type Delegate, DelegateMixin } from '../../../core/media/delegate';
import { CustomMediaMixin } from '../custom-media-element';
import { MediaProxyMixin } from '../proxy';
import { CustomVideoElement } from '../custom-media-element';
import { VideoProxy } from '../proxy';
export class DashMediaDelegateBase implements Delegate {
export class DashMediaDelegate implements Delegate {
#engine: dashjs.MediaPlayerClass;
constructor() {
@@ -39,10 +39,7 @@ export class DashMediaDelegateBase implements Delegate {
}
// This is used by the web component because it needs to extend HTMLElement!
export class DashCustomMedia extends DelegateMixin(
CustomMediaMixin(globalThis.HTMLElement ?? class {}, { tag: 'video' }),
DashMediaDelegateBase
) {}
export class DashCustomMedia extends DelegateMixin(CustomVideoElement, DashMediaDelegate) {}
// This is used by the React component.
export class DashMedia extends DelegateMixin(MediaProxyMixin, DashMediaDelegateBase) {}
export class DashMedia extends DelegateMixin(VideoProxy, DashMediaDelegate) {}
+130 -17
View File
@@ -1,10 +1,25 @@
import Hls from 'hls.js';
import { type Delegate, DelegateMixin } from '../../../core/media/delegate';
import { CustomMediaMixin } from '../custom-media-element';
import { MediaProxyMixin } from '../proxy';
import { CustomVideoElement } from '../custom-media-element';
import { VideoProxy } from '../proxy';
import { HlsMediaTextTracksMixin } from './text-tracks';
export { Hls };
export type PlaybackType = (typeof PlaybackTypes)[keyof typeof PlaybackTypes];
export type SourceType = (typeof SourceTypes)[keyof typeof SourceTypes];
export const PlaybackTypes = {
MSE: 'mse',
NATIVE: 'native',
};
export const SourceTypes = {
M3U8: 'application/vnd.apple.mpegurl',
MP4: 'video/mp4',
};
const defaultConfig = {
backBufferLength: 30,
renderTextTracksNatively: false,
@@ -14,40 +29,138 @@ const defaultConfig = {
};
export class HlsMediaDelegateBase implements Delegate {
#engine = Hls.isSupported() ? new Hls(defaultConfig) : null;
#target: EventTarget | null = null;
#engine: Hls | null = null;
#loadRequested?: Promise<void> | null;
#src: string = '';
#debug: boolean = false;
#type: SourceType | undefined;
#preferPlayback: PlaybackType | undefined = 'mse';
constructor() {
this.#initialize();
}
#initialize(): void {
this.#engine?.destroy();
this.#engine = null;
if (this.type !== SourceTypes.M3U8) return;
if (this.#preferPlayback === PlaybackTypes.NATIVE) return;
if (!Hls.isSupported()) return;
this.#engine = new Hls({
...defaultConfig,
debug: this.#debug,
});
if (this.#target) {
this.#engine.attachMedia(this.#target as HTMLMediaElement);
}
if (this.#src) {
this.#requestLoad();
}
}
/** 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.#initialize();
}
/** 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.#initialize();
}
/**
* Whether to prefer `'mse'` (hls.js) or `'native'` (browser-built-in) HLS
* playback. Changing this re-initializes the delegate.
*/
get preferPlayback(): PlaybackType | undefined {
return this.#preferPlayback;
}
set preferPlayback(value: PlaybackType | undefined) {
if (this.#preferPlayback === value) return;
this.#preferPlayback = value;
this.#initialize();
}
/** The HLS source URL to load. */
set src(src: string) {
this.#src = src;
this.#requestLoad();
}
get src(): string {
return this.#src;
}
async #requestLoad() {
if (this.#loadRequested) return;
await (this.#loadRequested = Promise.resolve());
this.#loadRequested = null;
this.load();
}
load(): void {
if (this.#engine) {
this.#engine.loadSource(this.#src);
} else if (this.#target) {
(this.#target as HTMLMediaElement).src = this.#src;
}
}
attach(target: EventTarget): void {
this.#target = target;
this.#engine?.attachMedia(target as HTMLMediaElement);
}
detach(): void {
this.#engine?.detachMedia();
this.#target = null;
}
destroy(): void {
this.#engine?.destroy();
}
set src(src: string) {
this.#engine?.loadSource(src);
}
get src(): string {
return this.#engine?.url ?? '';
this.#engine = null;
this.#target = null;
}
}
const HlsMediaDelegate = HlsMediaTextTracksMixin(HlsMediaDelegateBase);
function inferSourceType(src: string): SourceType {
const path = src.split(/[?#]/)[0] ?? '';
if (path.endsWith('.mp4')) return SourceTypes.MP4;
return SourceTypes.M3U8;
}
export const HlsMediaDelegate = HlsMediaTextTracksMixin(HlsMediaDelegateBase);
// This is used by the web component because it needs to extend HTMLElement!
export class HlsCustomMedia extends DelegateMixin(
CustomMediaMixin(globalThis.HTMLElement ?? class {}, { tag: 'video' }),
HlsMediaDelegate
) {}
export class HlsCustomMedia extends DelegateMixin(CustomVideoElement, HlsMediaDelegate) {}
// This is used by the React component.
export class HlsMedia extends DelegateMixin(MediaProxyMixin, HlsMediaDelegate) {}
export class HlsMedia extends DelegateMixin(VideoProxy, HlsMediaDelegate) {}
+11
View File
@@ -0,0 +1,11 @@
const getEnvPlayerVersion = () => {
try {
// @ts-expect-error
return __PLAYER_VERSION__ as string;
} catch {}
return 'UNKNOWN';
};
const player_version: string = getEnvPlayerVersion();
export const getPlayerVersion = () => player_version;
+224
View File
@@ -0,0 +1,224 @@
import Mux from 'mux-embed';
import { type Delegate, DelegateMixin } from '../../../core/media/delegate';
import { CustomVideoElement } from '../custom-media-element';
import { Hls, HlsMediaDelegate } from '../hls';
import { VideoProxy } from '../proxy';
import { getPlayerVersion } from './env';
import type { MuxDataSdk } from './types';
const MUX_VIDEO_DOMAIN = 'mux.com';
export class MuxMediaDelegate extends HlsMediaDelegate implements Delegate {
static PLAYER_SOFTWARE_NAME = '';
#playbackId: string | null = null;
#customDomain: string = MUX_VIDEO_DOMAIN;
#MuxDataSdk: MuxDataSdk | undefined = Mux;
#beaconCollectionDomain: string | undefined;
#disableCookies: boolean = false;
#metadata: Record<string, any> | undefined;
#envKey: string | undefined;
#playerSoftwareName: string | undefined = (this.constructor as typeof MuxMediaDelegate).PLAYER_SOFTWARE_NAME;
#playerSoftwareVersion: string | undefined = getPlayerVersion();
#playerInitTime: number | undefined = this.#generatePlayerInitTime();
get playbackId() {
return this.#playbackId;
}
set playbackId(value: string | null) {
if (this.#playbackId === value) return;
this.#playbackId = value;
this.#syncSrc();
}
get customDomain(): string {
return this.#customDomain;
}
set customDomain(value: string) {
const normalized = value || MUX_VIDEO_DOMAIN;
if (this.#customDomain === normalized) return;
this.#customDomain = normalized;
this.#syncSrc();
}
get MuxDataSdk() {
return this.#MuxDataSdk;
}
set MuxDataSdk(value) {
this.#MuxDataSdk = value;
}
get beaconCollectionDomain(): string | undefined {
return this.#beaconCollectionDomain;
}
set beaconCollectionDomain(value: string | undefined) {
this.#beaconCollectionDomain = value;
}
get disableCookies(): boolean {
return this.#disableCookies;
}
set disableCookies(value: boolean) {
this.#disableCookies = value;
}
get envKey(): string | undefined {
return this.#envKey;
}
set envKey(value: string | undefined) {
this.#envKey = value;
}
get playerSoftwareName(): string | undefined {
return this.#playerSoftwareName;
}
set playerSoftwareName(value: string | undefined) {
this.#playerSoftwareName = value;
}
get playerSoftwareVersion(): string | undefined {
return this.#playerSoftwareVersion;
}
set playerSoftwareVersion(value: string | undefined) {
this.#playerSoftwareVersion = value;
}
get playerInitTime(): number | undefined {
return this.#playerInitTime;
}
set playerInitTime(value: number | undefined) {
this.#playerInitTime = value;
}
get metadata(): Record<string, any> | undefined {
return this.#metadata;
}
set metadata(value: Record<string, any> | undefined) {
this.#metadata = value;
}
attach(target: EventTarget): void {
super.attach(target);
}
detach(): void {
this.#MuxDataSdk?.destroyMonitor(this.target as HTMLMediaElement);
super.detach();
}
load(): void {
this.#initializeMuxDataSdk();
super.load();
}
#syncSrc(): void {
this.src = this.#playbackId ? toSrc(this.#playbackId, this.#customDomain) : '';
}
#initializeMuxDataSdk(): void {
const target = this.target as HTMLMediaElement;
if (!this.#MuxDataSdk || !target || target.mux) return;
const {
debug,
beaconCollectionDomain,
disableCookies,
engine: hlsjs,
envKey: env_key,
playerSoftwareName: player_software_name,
playerSoftwareVersion: player_software_version,
playerInitTime: player_init_time,
metadata = {},
} = this;
const { view_session_id = this.#MuxDataSdk?.utils.generateUUID() } = metadata;
const video_id = toVideoId(this);
metadata.view_session_id = view_session_id;
metadata.video_id = video_id;
this.#MuxDataSdk?.monitor(this.target as HTMLMediaElement, {
debug,
...(beaconCollectionDomain ? { beaconCollectionDomain } : {}),
...(disableCookies ? { disableCookies } : {}),
...(hlsjs ? { hlsjs } : {}),
Hls,
data: {
...(env_key ? { env_key } : {}),
...(player_software_name ? { player_software_name } : {}),
// NOTE: Adding this because there appears to be some instability on whether
// player_software_name or player_software "wins" for Mux Data (CJP)
...(player_software_name ? { player_software: player_software_name } : {}),
...(player_software_version ? { player_software_version } : {}),
...(player_init_time ? { player_init_time } : {}),
// Use any metadata passed in programmatically (which may override the defaults above)
...metadata,
},
});
}
#generatePlayerInitTime(): number | undefined {
if (!this.#MuxDataSdk) return undefined;
return this.#MuxDataSdk.utils.now();
}
}
function toSrc(playbackId: string, customDomain: string): string {
return `https://stream.${customDomain}/${playbackId}.m3u8`;
}
type MuxSrcProps = Pick<MuxMediaDelegate, 'playbackId' | 'src' | 'customDomain'>;
export function toVideoId(props: MuxSrcProps & Pick<MuxMediaDelegate, 'metadata'>): string | undefined {
if (props.metadata?.video_id) return props.metadata.video_id;
if (!isMuxVideoSrc(props)) return props.src;
return toPlaybackIdFromParameterized(props.playbackId) ?? toPlaybackIdFromSrc(props.src) ?? props.src;
}
function toPlaybackIdFromParameterized(playbackId: MuxMediaDelegate['playbackId']): string | undefined {
if (!playbackId) return undefined;
const [id] = playbackId.split('?');
return id || undefined;
}
export function toPlaybackIdFromSrc(src: MuxMediaDelegate['src']): string | undefined {
if (!src || !src.startsWith('https://stream.')) return undefined;
const [playbackId] = new URL(src).pathname.slice(1).split(/\.m3u8|\//);
return playbackId || undefined;
}
export function isMuxVideoSrc({ playbackId, src, customDomain }: MuxSrcProps): boolean {
if (playbackId) return true;
if (typeof src !== 'string') return false;
const base = window?.location.href;
const hostname = new URL(src, base).hostname.toLocaleLowerCase();
return hostname.includes(MUX_VIDEO_DOMAIN) || (!!customDomain && hostname.includes(customDomain.toLocaleLowerCase()));
}
export class MuxVideoDelegate extends MuxMediaDelegate {
static PLAYER_SOFTWARE_NAME = 'mux-video';
}
export class MuxAudioDelegate extends MuxMediaDelegate {
static PLAYER_SOFTWARE_NAME = 'mux-audio';
}
export class MuxCustomMedia extends DelegateMixin(CustomVideoElement, MuxMediaDelegate) {}
export class MuxMedia extends DelegateMixin(VideoProxy, MuxMediaDelegate) {}
export class MuxCustomVideo extends DelegateMixin(CustomVideoElement, MuxVideoDelegate) {}
export class MuxVideo extends DelegateMixin(VideoProxy, MuxVideoDelegate) {}
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { MuxMediaDelegate } from '..';
describe('MuxMediaDelegate', () => {
it('defaults playbackId to null', () => {
const delegate = new MuxMediaDelegate();
expect(delegate.playbackId).toBeNull();
});
it('defaults customDomain to mux.com', () => {
const delegate = new MuxMediaDelegate();
expect(delegate.customDomain).toBe('mux.com');
});
it('sets src when playbackId is set', () => {
const delegate = new MuxMediaDelegate();
delegate.playbackId = 'abc123';
expect(delegate.src).toBe('https://stream.mux.com/abc123.m3u8');
});
it('uses customDomain in the generated src', () => {
const delegate = new MuxMediaDelegate();
delegate.customDomain = 'example.com';
delegate.playbackId = 'abc123';
expect(delegate.src).toBe('https://stream.example.com/abc123.m3u8');
});
it('updates src when customDomain changes after playbackId', () => {
const delegate = new MuxMediaDelegate();
delegate.playbackId = 'abc123';
expect(delegate.src).toBe('https://stream.mux.com/abc123.m3u8');
delegate.customDomain = 'custom.tv';
expect(delegate.src).toBe('https://stream.custom.tv/abc123.m3u8');
});
it('falls back to default domain when customDomain is set to empty', () => {
const delegate = new MuxMediaDelegate();
delegate.customDomain = 'custom.tv';
delegate.playbackId = 'abc123';
expect(delegate.src).toBe('https://stream.custom.tv/abc123.m3u8');
delegate.customDomain = '';
expect(delegate.src).toBe('https://stream.mux.com/abc123.m3u8');
});
it('does not update src when playbackId is set to the same value', () => {
const delegate = new MuxMediaDelegate();
delegate.playbackId = 'abc123';
expect(delegate.src).toBe('https://stream.mux.com/abc123.m3u8');
delegate.src = 'https://override.example.com/video.m3u8';
delegate.playbackId = 'abc123';
expect(delegate.src).toBe('https://override.example.com/video.m3u8');
});
it('does not trigger syncSrc when customDomain is set to empty while already default', () => {
const delegate = new MuxMediaDelegate();
delegate.playbackId = 'abc123';
expect(delegate.src).toBe('https://stream.mux.com/abc123.m3u8');
delegate.src = 'https://override.example.com/video.m3u8';
delegate.customDomain = '';
expect(delegate.src).toBe('https://override.example.com/video.m3u8');
});
it('clears src when playbackId is null and customDomain changes', () => {
const delegate = new MuxMediaDelegate();
delegate.src = 'https://manual.example.com/video.m3u8';
delegate.customDomain = 'custom.tv';
expect(delegate.src).toBe('');
});
});
+2
View File
@@ -0,0 +1,2 @@
/// <reference path="../../../../node_modules/mux-embed/dist/types/mux-embed.d.ts" preserve="true" />
export type { Mux as MuxDataSdk } from 'mux-embed';
+1 -1
View File
@@ -1,6 +1,6 @@
import { ProxyMixin } from '../../core/media/proxy';
export const MediaProxyMixin = ProxyMixin(
export const VideoProxy = ProxyMixin(
globalThis.HTMLVideoElement ?? class {},
globalThis.HTMLMediaElement ?? class {},
globalThis.EventTarget ?? class {}
@@ -1,13 +1,10 @@
import { SpfMedia as SpfMediaDelegate } from '@videojs/spf/dom';
import { DelegateMixin } from '../../../core/media/delegate';
import { CustomMediaMixin } from '../custom-media-element';
import { MediaProxyMixin } from '../proxy';
import { CustomVideoElement } from '../custom-media-element';
import { VideoProxy } from '../proxy';
// This is used by the web component because it needs to extend HTMLElement!
export class SimpleHlsCustomMedia extends DelegateMixin(
CustomMediaMixin(globalThis.HTMLElement ?? class {}, { tag: 'video' }),
SpfMediaDelegate
) {}
export class SimpleHlsCustomMedia extends DelegateMixin(CustomVideoElement, SpfMediaDelegate) {}
// This is used by the React component.
export class SimpleHlsMedia extends DelegateMixin(MediaProxyMixin, SpfMediaDelegate) {}
export class SimpleHlsMedia extends DelegateMixin(VideoProxy, SpfMediaDelegate) {}