From 9fac0fca3d5af44c76c9845da37b5197dd8538df Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Tue, 7 Apr 2026 14:59:22 -0500 Subject: [PATCH] feat(packages): add mux-audio element and react component (#1259) --- apps/sandbox/app/constants.ts | 1 + apps/sandbox/app/shell/app.tsx | 7 +-- apps/sandbox/app/shell/navbar.tsx | 5 ++- apps/sandbox/templates/cdn/main.ts | 15 ++++--- .../templates/html-mux-audio/index.html | 14 ++++++ apps/sandbox/templates/html-mux-audio/main.ts | 44 +++++++++++++++++++ .../templates/react-mux-audio/index.html | 14 ++++++ .../templates/react-mux-audio/main.tsx | 33 ++++++++++++++ packages/core/src/dom/media/mux/index.ts | 8 +++- packages/core/src/dom/media/proxy.ts | 2 + packages/core/src/dom/media/types.ts | 2 +- packages/html/src/cdn/media/mux-audio.ts | 1 + packages/html/src/define/media/mux-audio.ts | 14 ++++++ packages/html/src/media/mux-audio/index.ts | 18 ++++++++ packages/html/tsdown.cdn.config.ts | 2 +- packages/react/src/media/mux-audio/index.tsx | 25 +++++++++++ 16 files changed, 192 insertions(+), 13 deletions(-) create mode 100644 apps/sandbox/templates/html-mux-audio/index.html create mode 100644 apps/sandbox/templates/html-mux-audio/main.ts create mode 100644 apps/sandbox/templates/react-mux-audio/index.html create mode 100644 apps/sandbox/templates/react-mux-audio/main.tsx create mode 100644 packages/html/src/cdn/media/mux-audio.ts create mode 100644 packages/html/src/define/media/mux-audio.ts create mode 100644 packages/html/src/media/mux-audio/index.ts create mode 100644 packages/react/src/media/mux-audio/index.tsx diff --git a/apps/sandbox/app/constants.ts b/apps/sandbox/app/constants.ts index 514e79f0..c5ff095d 100644 --- a/apps/sandbox/app/constants.ts +++ b/apps/sandbox/app/constants.ts @@ -6,6 +6,7 @@ export const PRESETS = [ 'hls-video', 'native-hls-video', 'mux-video', + 'mux-audio', 'simple-hls-video', 'dash-video', 'audio', diff --git a/apps/sandbox/app/shell/app.tsx b/apps/sandbox/app/shell/app.tsx index c168a74b..6de7c756 100644 --- a/apps/sandbox/app/shell/app.tsx +++ b/apps/sandbox/app/shell/app.tsx @@ -78,9 +78,9 @@ export function App() { } }, [preset, source, setSource]); - // Constrain source to Mux playbackId sources when switching to mux-video + // Constrain source to Mux playbackId sources when switching to mux-video / mux-audio useEffect(() => { - if (preset === 'mux-video' && !SOURCES[source].playbackId) { + if ((preset === 'mux-video' || preset === 'mux-audio') && !SOURCES[source].playbackId) { setSource(DEFAULT_SOURCE); } }, [preset, source, setSource]); @@ -97,7 +97,7 @@ export function App() { ? MP4_SOURCE_IDS : preset === 'dash-video' ? DASH_SOURCE_IDS - : preset === 'mux-video' + : preset === 'mux-video' || preset === 'mux-audio' ? MUX_SOURCE_IDS : NON_DASH_SOURCE_IDS; @@ -120,6 +120,7 @@ export function App() { isBackgroundVideo={preset === 'background-video'} isSimpleHlsVideo={preset === 'simple-hls-video'} isMuxVideo={preset === 'mux-video'} + isMuxAudio={preset === 'mux-audio'} platforms={PLATFORMS} stylings={STYLINGS} presets={PRESETS} diff --git a/apps/sandbox/app/shell/navbar.tsx b/apps/sandbox/app/shell/navbar.tsx index db2c626a..04416306 100644 --- a/apps/sandbox/app/shell/navbar.tsx +++ b/apps/sandbox/app/shell/navbar.tsx @@ -17,6 +17,7 @@ type NavbarProps = { isBackgroundVideo: boolean; isSimpleHlsVideo: boolean; isMuxVideo: boolean; + isMuxAudio: boolean; platforms: readonly Platform[]; stylings: readonly Styling[]; presets: readonly Preset[]; @@ -36,6 +37,7 @@ const PRESET_LABELS: Record = { 'hls-video': 'HLS Video', 'native-hls-video': 'Native HLS Video', 'mux-video': 'Mux Video', + 'mux-audio': 'Mux Audio', 'simple-hls-video': 'Simple HLS Video', 'dash-video': 'DASH Video', audio: 'Audio', @@ -57,6 +59,7 @@ export function Navbar({ isBackgroundVideo, isSimpleHlsVideo, isMuxVideo, + isMuxAudio, platforms, stylings, presets, @@ -111,7 +114,7 @@ export function Navbar({ options={availableSources .filter((id) => { if (isSimpleHlsVideo) return sources[id].subType === 'mp4'; - if (isMuxVideo) return sources[id].type !== 'dash'; + if (isMuxVideo || isMuxAudio) return sources[id].type !== 'dash'; return true; }) .map((id) => ({ value: id, label: sources[id].label }))} diff --git a/apps/sandbox/templates/cdn/main.ts b/apps/sandbox/templates/cdn/main.ts index acd41551..acb37faf 100644 --- a/apps/sandbox/templates/cdn/main.ts +++ b/apps/sandbox/templates/cdn/main.ts @@ -31,6 +31,7 @@ async function loadCdnPreset(preset: Preset, skin: Skin) { else await import('@videojs/html/cdn/video'); break; case 'audio': + case 'mux-audio': if (skin === 'minimal') await import('@videojs/html/cdn/audio-minimal'); else await import('@videojs/html/cdn/audio'); break; @@ -48,6 +49,9 @@ async function loadCdnMedia(preset: Preset) { case 'mux-video': await import('@videojs/html/cdn/media/mux-video'); break; + case 'mux-audio': + await import('@videojs/html/cdn/media/mux-audio'); + break; case 'native-hls-video': await import('@videojs/html/cdn/media/native-hls-video'); break; @@ -66,13 +70,13 @@ async function loadCdnMedia(preset: Preset) { function getPlayerTag(preset: Preset): string { if (preset === 'background-video') return 'background-video-player'; - if (preset === 'audio') return 'audio-player'; + if (preset === 'audio' || preset === 'mux-audio') return 'audio-player'; return 'video-player'; } function getSkinTag(preset: Preset, skin: Skin): string { if (preset === 'background-video') return 'background-video-skin'; - if (preset === 'audio') return CSS_SKIN_TAGS[skin].audio; + if (preset === 'audio' || preset === 'mux-audio') return CSS_SKIN_TAGS[skin].audio; return CSS_SKIN_TAGS[skin].video; } @@ -80,6 +84,7 @@ function getMediaTag(preset: Preset): string { const tags: Partial> = { 'hls-video': 'hls-video', 'mux-video': 'mux-video', + 'mux-audio': 'mux-audio', 'native-hls-video': 'native-hls-video', 'simple-hls-video': 'simple-hls-video', 'dash-video': 'dash-video', @@ -91,7 +96,7 @@ function getMediaTag(preset: Preset): string { } function loadStylesheets(preset: Preset, skin: Skin) { - if (preset === 'audio') loadAudioStylesheets(skin); + if (preset === 'audio' || preset === 'mux-audio') loadAudioStylesheets(skin); else if (preset !== 'background-video') loadVideoStylesheets(skin); // Background CSS is loaded via dynamic import in loadCdnPreset. } @@ -126,7 +131,7 @@ async function render() { const sourceAttr = preset === 'background-video' ? `src="${BACKGROUND_VIDEO_SRC}"` - : preset === 'mux-video' && source.type !== 'mp4' + : (preset === 'mux-video' || preset === 'mux-audio') && source.type !== 'mp4' ? `playback-id="${source.playbackId}"` : `src="${source.url}"`; @@ -147,7 +152,7 @@ async function render() { return; } - if (preset === 'audio') { + if (preset === 'audio' || preset === 'mux-audio') { root.innerHTML = html`
<${playerTag}> diff --git a/apps/sandbox/templates/html-mux-audio/index.html b/apps/sandbox/templates/html-mux-audio/index.html new file mode 100644 index 00000000..29ef37a0 --- /dev/null +++ b/apps/sandbox/templates/html-mux-audio/index.html @@ -0,0 +1,14 @@ + + + + + + Sandbox — HTML Mux Audio + + + + +
+ + + diff --git a/apps/sandbox/templates/html-mux-audio/main.ts b/apps/sandbox/templates/html-mux-audio/main.ts new file mode 100644 index 00000000..d95d5e6b --- /dev/null +++ b/apps/sandbox/templates/html-mux-audio/main.ts @@ -0,0 +1,44 @@ +import '@app/styles.css'; +import '@videojs/html/audio/player'; +import '@videojs/html/media/mux-audio'; +import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state'; +import { loadAudioSkinTag } from '@app/shared/html/skins'; +import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener'; +import { SOURCES } from '@app/shared/sources'; + +const html = String.raw; + +const state = createHtmlSandboxState(); +const loadLatest = createLatestLoader(); + +async function render() { + const tag = await loadLatest(() => loadAudioSkinTag(state.skin, state.styling)); + if (!tag) return; + + const sourceAttr = + SOURCES[state.source].type === 'mp4' + ? `src="${SOURCES[state.source].url}"` + : `playback-id="${SOURCES[state.source].playbackId}"`; + + document.getElementById('root')!.innerHTML = html` +
+ + <${tag}> + + + +
+ `; +} + +render(); + +onSkinChange((skin) => { + state.skin = skin; + render(); +}); + +onSourceChange((source) => { + state.source = source; + render(); +}); diff --git a/apps/sandbox/templates/react-mux-audio/index.html b/apps/sandbox/templates/react-mux-audio/index.html new file mode 100644 index 00000000..0e9ab765 --- /dev/null +++ b/apps/sandbox/templates/react-mux-audio/index.html @@ -0,0 +1,14 @@ + + + + + + Sandbox — React Mux Audio + + + + +
+ + + diff --git a/apps/sandbox/templates/react-mux-audio/main.tsx b/apps/sandbox/templates/react-mux-audio/main.tsx new file mode 100644 index 00000000..097e366d --- /dev/null +++ b/apps/sandbox/templates/react-mux-audio/main.tsx @@ -0,0 +1,33 @@ +import '@app/styles.css'; +import { AudioProvider } from '@app/shared/react/providers'; +import { AudioSkinComponent } from '@app/shared/react/skins'; +import { useSkin } from '@app/shared/react/use-skin'; +import { useSource } from '@app/shared/react/use-source'; +import { SOURCES } from '@app/shared/sources'; +import type { Styling } from '@app/types'; +import { MuxAudio } from '@videojs/react/media/mux-audio'; +import { useMemo } from 'react'; +import { createRoot } from 'react-dom/client'; + +function readStyling(): Styling { + return new URLSearchParams(location.search).get('styling') === 'tailwind' ? 'tailwind' : 'css'; +} + +function App() { + const skin = useSkin(); + const source = useSource(); + const styling = useMemo(readStyling, []); + + const sourceAttr = + SOURCES[source].type === 'mp4' ? { src: SOURCES[source].url } : { playbackId: SOURCES[source].playbackId }; + + return ( + + + + + + ); +} + +createRoot(document.getElementById('root')!).render(); diff --git a/packages/core/src/dom/media/mux/index.ts b/packages/core/src/dom/media/mux/index.ts index f4b27d03..67cae943 100644 --- a/packages/core/src/dom/media/mux/index.ts +++ b/packages/core/src/dom/media/mux/index.ts @@ -1,9 +1,9 @@ import Mux from 'mux-embed'; import { DelegateMixin } from '../../../core/media/delegate'; -import { CustomVideoElement } from '../custom-media-element'; +import { CustomAudioElement, CustomVideoElement } from '../custom-media-element'; import { Hls, HlsMediaDelegate } from '../hls'; -import { VideoProxy } from '../proxy'; +import { AudioProxy, VideoProxy } from '../proxy'; import { getPlayerVersion } from './env'; import type { MuxDataSdk } from './types'; @@ -235,3 +235,7 @@ export class MuxMedia extends DelegateMixin(VideoProxy, MuxMediaDelegate) {} export class MuxCustomVideo extends DelegateMixin(CustomVideoElement, MuxVideoDelegate) {} export class MuxVideo extends DelegateMixin(VideoProxy, MuxVideoDelegate) {} + +export class MuxCustomAudio extends DelegateMixin(CustomAudioElement, MuxAudioDelegate) {} + +export class MuxAudio extends DelegateMixin(AudioProxy, MuxAudioDelegate) {} diff --git a/packages/core/src/dom/media/proxy.ts b/packages/core/src/dom/media/proxy.ts index 08e7e79d..44053767 100644 --- a/packages/core/src/dom/media/proxy.ts +++ b/packages/core/src/dom/media/proxy.ts @@ -1,3 +1,5 @@ import { ProxyMixin } from '../../core/media/proxy'; export const VideoProxy = ProxyMixin(globalThis.HTMLVideoElement ?? class {}); + +export const AudioProxy = ProxyMixin(globalThis.HTMLAudioElement ?? class {}); diff --git a/packages/core/src/dom/media/types.ts b/packages/core/src/dom/media/types.ts index 0946156d..ef08effe 100644 --- a/packages/core/src/dom/media/types.ts +++ b/packages/core/src/dom/media/types.ts @@ -20,7 +20,7 @@ export type MediaBaseApi = { paused: boolean; }; -export type MediaApi = WithOptional; +export type MediaApi = WithOptional; export type Media = HTMLMediaElement | HTMLAudioElement | HTMLVideoElement; diff --git a/packages/html/src/cdn/media/mux-audio.ts b/packages/html/src/cdn/media/mux-audio.ts new file mode 100644 index 00000000..dabd4094 --- /dev/null +++ b/packages/html/src/cdn/media/mux-audio.ts @@ -0,0 +1 @@ +import '../../define/media/mux-audio'; diff --git a/packages/html/src/define/media/mux-audio.ts b/packages/html/src/define/media/mux-audio.ts new file mode 100644 index 00000000..94376cf7 --- /dev/null +++ b/packages/html/src/define/media/mux-audio.ts @@ -0,0 +1,14 @@ +import { MuxAudio } from '../../media/mux-audio'; +import { safeDefine } from '../safe-define'; + +export class MuxAudioElement extends MuxAudio { + static readonly tagName = 'mux-audio'; +} + +safeDefine(MuxAudioElement); + +declare global { + interface HTMLElementTagNameMap { + [MuxAudioElement.tagName]: MuxAudioElement; + } +} diff --git a/packages/html/src/media/mux-audio/index.ts b/packages/html/src/media/mux-audio/index.ts new file mode 100644 index 00000000..0f1709c7 --- /dev/null +++ b/packages/html/src/media/mux-audio/index.ts @@ -0,0 +1,18 @@ +import { MuxCustomAudio, MuxMediaDelegate } from '@videojs/core/dom/media/mux'; +import { MediaAttachMixin } from '../../store/media-attach-mixin'; +import { MediaPropsMixin } from '../../utils/media-props-mixin'; + +export class MuxAudio extends MediaPropsMixin(MediaAttachMixin(MuxCustomAudio), MuxMediaDelegate) { + constructor() { + super(); + this.attach(this.target); + } + + disconnectedCallback(): void { + super.disconnectedCallback?.(); + + if (!this.hasAttribute('keep-alive')) { + this.destroy(); + } + } +} diff --git a/packages/html/tsdown.cdn.config.ts b/packages/html/tsdown.cdn.config.ts index c820b150..eb7a1ce3 100644 --- a/packages/html/tsdown.cdn.config.ts +++ b/packages/html/tsdown.cdn.config.ts @@ -23,7 +23,7 @@ const presets = [ 'audio-minimal-ui', 'background', ]; -const media = ['hls-video', 'mux-video', 'native-hls-video', 'simple-hls-video', 'dash-video']; +const media = ['hls-video', 'mux-audio', 'mux-video', 'native-hls-video', 'simple-hls-video', 'dash-video']; const entries = [ ...presets.map((name) => ({ src: `src/cdn/${name}.ts`, name })), diff --git a/packages/react/src/media/mux-audio/index.tsx b/packages/react/src/media/mux-audio/index.tsx new file mode 100644 index 00000000..0ee9d233 --- /dev/null +++ b/packages/react/src/media/mux-audio/index.tsx @@ -0,0 +1,25 @@ +import type { InferDelegateProps } from '@videojs/core'; +import { MuxAudio as MuxAudioApi, MuxMediaDelegate } from '@videojs/core/dom/media/mux'; +import type { AudioHTMLAttributes, PropsWithChildren } 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 MuxAudioProps = PropsWithChildren> & + InferDelegateProps; + +export const MuxAudio = forwardRef(({ children, ...props }, ref) => { + const mediaApi = useMediaInstance(MuxAudioApi); + + const composedRef = useComposedRefs(attachMediaElement(mediaApi), ref); + + return ( + + ); +}); + +export default MuxAudio;