From 01cea7a3d115be81c132c1348d461635311d32b3 Mon Sep 17 00:00:00 2001 From: Darius Cepulis Date: Fri, 26 Jun 2026 14:32:49 -0700 Subject: [PATCH] feat: add DASH, Mux, and Vimeo as installation source types (UI + CLI) (#1732) Co-authored-by: Claude --- packages/cli/src/commands/docs.ts | 9 ++- packages/cli/src/commands/tests/docs.test.ts | 35 ++++++++++ packages/cli/src/site-modules.d.ts | 36 +++++++++- packages/cli/src/utils/format.ts | 5 +- packages/cli/src/utils/prompts.ts | 17 ++--- packages/cli/src/utils/tests/prompts.test.ts | 18 +++-- .../cli/src/utils/tests/site-aliases.test.ts | 1 + packages/cli/tsdown.config.ts | 4 ++ packages/cli/vitest.config.ts | 4 ++ .../installation/HTMLCdnCodeBlock.tsx | 9 ++- .../installation/HTMLInstallTabsClient.tsx | 12 +++- .../installation/RendererSelect.tsx | 28 +------- site/src/consts.ts | 6 ++ site/src/content/docs/how-to/installation.mdx | 2 +- .../installation/__tests__/cdn-code.test.ts | 46 +++++++++--- .../installation/__tests__/codegen.test.ts | 65 ++++++++++++++++- .../__tests__/detect-renderer.test.ts | 61 +++++++++++++--- .../__tests__/renderer-options.test.ts | 21 ++++++ site/src/utils/installation/cdn-code.ts | 33 ++++----- site/src/utils/installation/codegen.ts | 70 ++++++++++++------- .../src/utils/installation/detect-renderer.ts | 58 ++++++++------- .../utils/installation/renderer-options.ts | 21 ++++++ site/src/utils/installation/types.ts | 31 +++++++- 23 files changed, 444 insertions(+), 148 deletions(-) create mode 100644 site/src/utils/installation/__tests__/renderer-options.test.ts create mode 100644 site/src/utils/installation/renderer-options.ts diff --git a/packages/cli/src/commands/docs.ts b/packages/cli/src/commands/docs.ts index 79d9a077..39ee43df 100644 --- a/packages/cli/src/commands/docs.ts +++ b/packages/cli/src/commands/docs.ts @@ -1,5 +1,6 @@ import * as p from '@clack/prompts'; import { validateInstallationOptions } from '@/utils/installation/codegen'; +import { RENDERER_LABELS } from '@/utils/installation/renderer-options'; import type { InstallMethod, Renderer, UseCase } from '@/utils/installation/types'; import type { Framework } from '../utils/config.js'; import { getConfigValue } from '../utils/config.js'; @@ -58,7 +59,7 @@ function mapPresetToUseCase(preset: string): UseCase { return result; } -const ALL_RENDERERS: Renderer[] = ['html5-video', 'html5-audio', 'hls', 'background-video']; +const ALL_RENDERERS = Object.keys(RENDERER_LABELS) as Renderer[]; function validateMedia(media: string): Renderer { if (!ALL_RENDERERS.includes(media as Renderer)) { @@ -114,7 +115,7 @@ Installation flags (for docs how-to/installation): --preset --skin --source-url - --media + --media --install-method `; export async function handleDocs(flags: ParsedFlags, positionals: string[]): Promise { @@ -187,7 +188,9 @@ export async function handleDocs(flags: ParsedFlags, positionals: string[]): Pro // the non-interactive flag path so a `--install-method cdn` request for one // can't emit a broken snippet. if (opts.installMethod === 'cdn' && !supportsCdnInstall(opts.renderer)) { - console.error('Error: this source type has no CDN build. Install it with npm, pnpm, yarn, or bun.'); + console.error( + `Error: ${RENDERER_LABELS[opts.renderer]} has no CDN build. Install it with npm, pnpm, yarn, or bun.` + ); process.exit(1); } diff --git a/packages/cli/src/commands/tests/docs.test.ts b/packages/cli/src/commands/tests/docs.test.ts index aed9beb1..fcbcc62f 100644 --- a/packages/cli/src/commands/tests/docs.test.ts +++ b/packages/cli/src/commands/tests/docs.test.ts @@ -265,6 +265,41 @@ describe('handleDocs', () => { ]); expect(output()).toContain('background-video-player'); }); + + it('generates DASH media variant', async () => { + await handleDocs(htmlFlags({ media: 'dash' }), ['how-to/installation']); + const out = output(); + expect(out).toContain(' { + await handleDocs(htmlFlags({ media: 'mux-video' }), ['how-to/installation']); + const out = output(); + expect(out).toContain(' { + await handleDocs(htmlFlags({ media: 'vimeo' }), ['how-to/installation']); + const out = output(); + expect(out).toContain(' { + await handleDocs(htmlFlags({ media: 'mux-video', 'install-method': 'cdn' }), ['how-to/installation']); + const out = output(); + expect(out).toContain(' { + await expect( + handleDocs(htmlFlags({ media: 'vimeo', 'install-method': 'cdn' }), ['how-to/installation']) + ).rejects.toThrow(ExitError); + expect(errors()).toContain('no CDN build'); + }); }); describe('React framework', () => { diff --git a/packages/cli/src/site-modules.d.ts b/packages/cli/src/site-modules.d.ts index 592148f5..613bae79 100644 --- a/packages/cli/src/site-modules.d.ts +++ b/packages/cli/src/site-modules.d.ts @@ -7,7 +7,15 @@ */ declare module '@/utils/installation/types' { - export type Renderer = 'background-video' | 'hls' | 'html5-audio' | 'html5-video'; + export type Renderer = + | 'background-video' + | 'dash' + | 'hls' + | 'html5-audio' + | 'html5-video' + | 'mux-audio' + | 'mux-video' + | 'vimeo'; export type Skin = 'video' | 'audio' | 'minimal-video' | 'minimal-audio' | 'none'; export type UseCase = 'default-video' | 'default-audio' | 'background-video'; export type InstallMethod = 'cdn' | 'npm' | 'pnpm' | 'yarn' | 'bun'; @@ -31,7 +39,8 @@ declare module '@/utils/installation/codegen' { export function validateInstallationOptions(opts: InstallationOptions): ValidationResult; export function generateHTMLInstallCode( - opts: Pick + opts: Pick, + cdnMediaSubpaths: readonly string[] ): Record<'cdn' | 'npm' | 'pnpm' | 'yarn' | 'bun', string>; export function generateReactInstallCode(): Record<'npm' | 'pnpm' | 'yarn' | 'bun', string>; @@ -63,10 +72,31 @@ declare module '@/utils/installation/detect-renderer' { declare module '@/utils/installation/cdn-code' { import type { Renderer, Skin, UseCase } from '@/utils/installation/types'; - export function generateCdnCode(useCase: UseCase, skin: Skin, renderer: Renderer): string; + export function generateCdnCode( + useCase: UseCase, + skin: Skin, + renderer: Renderer, + cdnMediaSubpaths: readonly string[] + ): string; export function rendererSupportsCdn(renderer: Renderer, cdnMediaSubpaths: readonly string[]): boolean; } +declare module '@/utils/installation/renderer-options' { + import type { Renderer, UseCase } from '@/utils/installation/types'; + + // Mirrors the site's `SelectOption` shape, narrowed to the fields the CLI + // uses. The site module imports that type from a React component; the CLI only + // ever reads `value`/`label`. + interface RendererOption { + value: Renderer | null; + label: string; + disabled?: boolean; + } + + export const RENDERER_LABELS: Record; + export function buildOptions(useCase: UseCase): RendererOption[]; +} + declare module '@/content/cdn-media.json' { const entries: Array<{ id: string }>; export default entries; diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index e0995f1b..0ef4e536 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -1,3 +1,4 @@ +import cdnMedia from '@/content/cdn-media.json'; import { generateHTMLInstallCode, generateHTMLUsageCode, @@ -7,6 +8,8 @@ import { type InstallationOptions, } from '@/utils/installation/codegen'; +const CDN_MEDIA_SUBPATHS = cdnMedia.map((entry) => entry.id); + export function formatInstallationCode(opts: InstallationOptions): string { if (opts.framework === 'html') { return formatHTMLInstallation(opts); @@ -15,7 +18,7 @@ export function formatInstallationCode(opts: InstallationOptions): string { } function formatHTMLInstallation(opts: InstallationOptions): string { - const install = generateHTMLInstallCode(opts); + const install = generateHTMLInstallCode(opts, CDN_MEDIA_SUBPATHS); const usage = generateHTMLUsageCode(opts); const sections: string[] = []; diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index c0d49de8..e5e3d8ca 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -3,8 +3,8 @@ import cdnMedia from '@/content/cdn-media.json'; import { rendererSupportsCdn } from '@/utils/installation/cdn-code'; import type { InstallationOptions } from '@/utils/installation/codegen'; import { detectRenderer } from '@/utils/installation/detect-renderer'; +import { buildOptions } from '@/utils/installation/renderer-options'; import type { InstallMethod, Renderer, Skin, UseCase } from '@/utils/installation/types'; -import { VALID_RENDERERS } from '@/utils/installation/types'; import type { Framework } from './config.js'; const CDN_MEDIA_SUBPATHS = cdnMedia.map((entry) => entry.id); @@ -32,17 +32,12 @@ const PRESET_OPTIONS: Array<{ value: UseCase; label: string }> = [ { value: 'background-video', label: 'Background Video' }, ]; +// Reuse the installation page's option builder so labels and ordering stay in +// lockstep with the UI. function mediaOptionsForUseCase(useCase: UseCase): Array<{ value: Renderer; label: string }> { - const RENDERER_LABELS: Record = { - 'background-video': 'Background Video', - hls: 'HLS', - 'html5-audio': 'HTML5 Audio', - 'html5-video': 'HTML5 Video', - }; - - return VALID_RENDERERS[useCase].map((r) => ({ - value: r, - label: RENDERER_LABELS[r], + return buildOptions(useCase).map((option) => ({ + value: option.value as Renderer, + label: option.label, })); } diff --git a/packages/cli/src/utils/tests/prompts.test.ts b/packages/cli/src/utils/tests/prompts.test.ts index ff23486d..4211e105 100644 --- a/packages/cli/src/utils/tests/prompts.test.ts +++ b/packages/cli/src/utils/tests/prompts.test.ts @@ -1,19 +1,23 @@ import { describe, expect, it } from 'vitest'; import { supportsCdnInstall } from '../prompts.js'; -// Wires the cdn-media manifest into the CLI the same way the install page reads -// the cdnMedia collection. Every current renderer ships (or is covered by) a -// CDN build, so all resolve true; the no-CDN path (e.g. Vimeo) arrives with the -// new rendering engines. `rendererSupportsCdn`'s false branch is unit-tested in -// cdn-code.test.ts. +// Mirrors the install page's CDN gating: preset renderers and media renderers +// whose bundle ships a CDN build support CDN; Vimeo (no CDN build) does not. describe('supportsCdnInstall', () => { - it('returns true for preset renderers (covered by the preset bundle)', () => { + it('returns true for preset renderers', () => { expect(supportsCdnInstall('html5-video')).toBe(true); expect(supportsCdnInstall('html5-audio')).toBe(true); expect(supportsCdnInstall('background-video')).toBe(true); }); - it('returns true for hls, whose media bundle ships a CDN build', () => { + it('returns true for media renderers with a CDN build', () => { expect(supportsCdnInstall('hls')).toBe(true); + expect(supportsCdnInstall('dash')).toBe(true); + expect(supportsCdnInstall('mux-video')).toBe(true); + expect(supportsCdnInstall('mux-audio')).toBe(true); + }); + + it('returns false for vimeo, which has no CDN build', () => { + expect(supportsCdnInstall('vimeo')).toBe(false); }); }); diff --git a/packages/cli/src/utils/tests/site-aliases.test.ts b/packages/cli/src/utils/tests/site-aliases.test.ts index c59313fc..52ff374a 100644 --- a/packages/cli/src/utils/tests/site-aliases.test.ts +++ b/packages/cli/src/utils/tests/site-aliases.test.ts @@ -14,6 +14,7 @@ const ALIASED_FILES = [ 'utils/installation/types.ts', 'utils/installation/cdn-code.ts', 'utils/installation/detect-renderer.ts', + 'utils/installation/renderer-options.ts', 'consts.ts', ]; diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 5a9a3a68..941e58f5 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -23,6 +23,10 @@ export default defineConfig({ '@/utils/installation/types': resolve(__dirname, '../../site/src/utils/installation/types.ts'), '@/utils/installation/cdn-code': resolve(__dirname, '../../site/src/utils/installation/cdn-code.ts'), '@/utils/installation/detect-renderer': resolve(__dirname, '../../site/src/utils/installation/detect-renderer.ts'), + '@/utils/installation/renderer-options': resolve( + __dirname, + '../../site/src/utils/installation/renderer-options.ts' + ), '@/content/cdn-media.json': resolve(__dirname, '../../site/src/content/cdn-media.json'), '@/consts': resolve(__dirname, '../../site/src/consts.ts'), }, diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index c5286b1e..203e0bec 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -17,6 +17,10 @@ export default defineConfig({ __dirname, '../../site/src/utils/installation/detect-renderer.ts' ), + '@/utils/installation/renderer-options': resolve( + __dirname, + '../../site/src/utils/installation/renderer-options.ts' + ), // The real manifest is generated at build time (gitignored) and bundled // by tsdown. CLI tests are intentionally hermetic (`test` has no turbo // build dependency), so they resolve a committed fixture that mirrors the diff --git a/site/src/components/installation/HTMLCdnCodeBlock.tsx b/site/src/components/installation/HTMLCdnCodeBlock.tsx index 6296a64d..5beb17aa 100644 --- a/site/src/components/installation/HTMLCdnCodeBlock.tsx +++ b/site/src/components/installation/HTMLCdnCodeBlock.tsx @@ -3,10 +3,15 @@ import ClientCode from '@/components/Code/ClientCode'; import { renderer, skin, useCase } from '@/stores/installation'; import { generateCdnCode } from '@/utils/installation/cdn-code'; -export default function HTMLCdnCodeBlock() { +interface HTMLCdnCodeBlockProps { + /** Media subpaths that ship a CDN build, from the cdn-media manifest. */ + cdnMedia: string[]; +} + +export default function HTMLCdnCodeBlock({ cdnMedia }: HTMLCdnCodeBlockProps) { const $useCase = useStore(useCase); const $skin = useStore(skin); const $renderer = useStore(renderer); - return ; + return ; } diff --git a/site/src/components/installation/HTMLInstallTabsClient.tsx b/site/src/components/installation/HTMLInstallTabsClient.tsx index b58e3e7d..988ec17e 100644 --- a/site/src/components/installation/HTMLInstallTabsClient.tsx +++ b/site/src/components/installation/HTMLInstallTabsClient.tsx @@ -38,6 +38,16 @@ export default function HTMLInstallTabs({ cdnMedia }: HTMLInstallTabsProps) { return () => observer.disconnect(); }, []); + // When CDN availability flips, the tab set remounts and resets to its initial + // tab (cdn when available, else npm). That reset swaps in new DOM nodes rather + // than toggling `data-tab-active` on existing ones, so the observer above + // doesn't catch it — sync the store explicitly. Without this, a stale `cdn` + // can survive onto a renderer with no CDN build (e.g. Vimeo), where the usage + // block would then wrongly drop its required JS import lines. + useEffect(() => { + installMethod.set(supportsCdn ? 'cdn' : 'npm'); + }, [supportsCdn]); + return (
{/* Remount the tab set when CDN availability changes so the active tab @@ -60,7 +70,7 @@ export default function HTMLInstallTabs({ cdnMedia }: HTMLInstallTabsProps) { {supportsCdn && ( - + )} diff --git a/site/src/components/installation/RendererSelect.tsx b/site/src/components/installation/RendererSelect.tsx index e88856c7..1a4f5427 100644 --- a/site/src/components/installation/RendererSelect.tsx +++ b/site/src/components/installation/RendererSelect.tsx @@ -1,35 +1,11 @@ import { useStore } from '@nanostores/react'; import { useEffect } from 'react'; -import { Select, type SelectOption } from '@/components/Select'; +import { Select } from '@/components/Select'; import { renderer, sourceUrl, useCase } from '@/stores/installation'; import { articleFor, detectRenderer } from '@/utils/installation/detect-renderer'; -import type { Renderer, UseCase } from '@/utils/installation/types'; +import { buildOptions } from '@/utils/installation/renderer-options'; import { VALID_RENDERERS } from '@/utils/installation/types'; -const RENDERER_LABELS: Record = { - 'background-video': 'Background Video', - // cloudflare: 'Cloudflare', - // dash: 'DASH', - hls: 'HLS', - 'html5-audio': 'HTML5 Audio', - 'html5-video': 'HTML5 Video', - // jwplayer: 'JW Player', - // 'mux-audio': 'Mux', - // 'mux-background-video': 'Mux Background Video', - // 'mux-video': 'Mux', - // spotify: 'Spotify', - // vimeo: 'Vimeo', - // wistia: 'Wistia', - // youtube: 'YouTube', -}; - -function buildOptions(useCase: UseCase): SelectOption[] { - return VALID_RENDERERS[useCase].map((r) => ({ - value: r, - label: RENDERER_LABELS[r], - })); -} - export default function RendererSelect() { const $renderer = useStore(renderer); const $useCase = useStore(useCase); diff --git a/site/src/consts.ts b/site/src/consts.ts index b09864d6..54787fd3 100644 --- a/site/src/consts.ts +++ b/site/src/consts.ts @@ -44,3 +44,9 @@ export const VJS10_DEMO_VIDEO: VideoSource = { mp4: 'https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4', poster: 'https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/thumbnail.webp', }; + +// Standalone third-party samples for source types that aren't the shared Mux +// asset above: Mux doesn't serve DASH, and Vimeo is a hosting service. The DASH +// value matches the sample used by the site's DASH reference demo. +export const VJS10_DEMO_DASH = 'https://dash.akamaized.net/akamai/streamroot/050714/Spring_4Ktest.mpd'; +export const VJS10_DEMO_VIMEO = 'https://vimeo.com/648359100'; diff --git a/site/src/content/docs/how-to/installation.mdx b/site/src/content/docs/how-to/installation.mdx index 299a96c2..d5e84799 100644 --- a/site/src/content/docs/how-to/installation.mdx +++ b/site/src/content/docs/how-to/installation.mdx @@ -43,7 +43,7 @@ npx @videojs/cli docs how-to/installation \ --framework \ --preset \ --skin \ - --media \ + --media \ --source-url \ --install-method ``` diff --git a/site/src/utils/installation/__tests__/cdn-code.test.ts b/site/src/utils/installation/__tests__/cdn-code.test.ts index b7cddcf2..27f74fea 100644 --- a/site/src/utils/installation/__tests__/cdn-code.test.ts +++ b/site/src/utils/installation/__tests__/cdn-code.test.ts @@ -2,40 +2,65 @@ import { describe, expect, it } from 'vitest'; import { generateCdnCode, rendererSupportsCdn } from '../cdn-code'; describe('generateCdnCode', () => { + // Media subpaths that ship a CDN build. The media script is emitted only for + // renderers whose subpath is in this set. + const manifest = ['hlsjs-video', 'dash-video', 'mux-video', 'mux-audio']; + it('generates video preset CDN tags for html5-video', () => { - expect(generateCdnCode('default-video', 'video', 'html5-video')).toEqual( + expect(generateCdnCode('default-video', 'video', 'html5-video', manifest)).toEqual( `` ); }); it('includes hls media bundle when renderer is hls', () => { - expect(generateCdnCode('default-video', 'minimal-video', 'hls')).toEqual( + expect(generateCdnCode('default-video', 'minimal-video', 'hls', manifest)).toEqual( ` ` ); }); + it('includes the dash media bundle when renderer is dash', () => { + expect(generateCdnCode('default-video', 'video', 'dash', manifest)).toEqual( + ` +` + ); + }); + + it('includes the mux media bundle when renderer is mux-video', () => { + expect(generateCdnCode('default-video', 'video', 'mux-video', manifest)).toEqual( + ` +` + ); + }); + + it('omits the media script for a media renderer absent from the manifest', () => { + expect(generateCdnCode('default-video', 'video', 'vimeo', manifest)).toEqual( + `` + ); + }); + it('generates background preset CDN tags', () => { - expect(generateCdnCode('background-video', 'video', 'background-video')).toEqual( + expect(generateCdnCode('background-video', 'video', 'background-video', manifest)).toEqual( `` ); }); it('generates headless video CDN tag when skin is none', () => { - expect(generateCdnCode('default-video', 'none', 'html5-video')).toEqual( + expect(generateCdnCode('default-video', 'none', 'html5-video', manifest)).toEqual( `` ); }); it('generates headless audio CDN tag when skin is none', () => { - expect(generateCdnCode('default-audio', 'none', 'html5-audio')).toEqual( + expect(generateCdnCode('default-audio', 'none', 'html5-audio', manifest)).toEqual( `` ); }); }); describe('rendererSupportsCdn', () => { - const manifest = ['hlsjs-video']; + // Mirrors the manifest entries that ship a CDN build. + const manifest = ['hlsjs-video', 'dash-video', 'mux-video', 'mux-audio']; it('returns true for preset renderers (covered by the preset bundle, no media subpath)', () => { expect(rendererSupportsCdn('html5-video', manifest)).toBe(true); @@ -43,11 +68,14 @@ describe('rendererSupportsCdn', () => { expect(rendererSupportsCdn('background-video', manifest)).toBe(true); }); - it('returns true for a media renderer whose subpath is in the manifest', () => { + it('returns true for media renderers whose subpath is in the manifest', () => { expect(rendererSupportsCdn('hls', manifest)).toBe(true); + expect(rendererSupportsCdn('dash', manifest)).toBe(true); + expect(rendererSupportsCdn('mux-video', manifest)).toBe(true); + expect(rendererSupportsCdn('mux-audio', manifest)).toBe(true); }); - it('returns false for a media renderer whose subpath is absent from the manifest', () => { - expect(rendererSupportsCdn('hls', [])).toBe(false); + it('returns false for vimeo, which has no CDN build', () => { + expect(rendererSupportsCdn('vimeo', manifest)).toBe(false); }); }); diff --git a/site/src/utils/installation/__tests__/codegen.test.ts b/site/src/utils/installation/__tests__/codegen.test.ts index 8a5510be..ede39420 100644 --- a/site/src/utils/installation/__tests__/codegen.test.ts +++ b/site/src/utils/installation/__tests__/codegen.test.ts @@ -52,8 +52,10 @@ describe('validateInstallationOptions', () => { }); describe('generateHTMLInstallCode', () => { + const manifest = ['hlsjs-video', 'dash-video', 'mux-video', 'mux-audio']; + it('returns install commands for all methods', () => { - const result = generateHTMLInstallCode(baseHTML); + const result = generateHTMLInstallCode(baseHTML, manifest); expect(result.npm).toBe('npm install @videojs/html'); expect(result.pnpm).toBe('pnpm add @videojs/html'); expect(result.yarn).toBe('yarn add @videojs/html'); @@ -61,13 +63,13 @@ describe('generateHTMLInstallCode', () => { }); it('returns CDN script tags', () => { - const result = generateHTMLInstallCode(baseHTML); + const result = generateHTMLInstallCode(baseHTML, manifest); expect(result.cdn).toContain(' { - const result = generateHTMLInstallCode({ ...baseHTML, renderer: 'hls' }); + const result = generateHTMLInstallCode({ ...baseHTML, renderer: 'hls' }, manifest); expect(result.cdn).toContain('media/hlsjs-video.js'); }); }); @@ -133,6 +135,41 @@ describe('generateHTMLUsageCode', () => { expect(result.js).toContain("import '@videojs/html/media/hlsjs-video'"); }); + it('uses the dash-video tag, playsinline, and media import for DASH', () => { + const result = generateHTMLUsageCode({ ...baseHTML, renderer: 'dash' }); + expect(result.html).toContain(' { + const result = generateHTMLUsageCode({ ...baseHTML, renderer: 'mux-video' }); + expect(result.html).toContain(' { + const result = generateHTMLUsageCode({ ...baseHTML, renderer: 'vimeo' }); + expect(result.html).toContain(' { + const result = generateHTMLUsageCode({ + ...baseHTML, + useCase: 'default-audio', + skin: 'audio', + renderer: 'mux-audio', + }); + expect(result.html).toContain(' { const result = generateHTMLUsageCode({ ...baseHTML, skin: 'minimal-video' }); expect(result.html).toContain(''); @@ -179,6 +216,28 @@ describe('generateReactCreateCode', () => { expect(code).toContain(''); }); + it('uses separate media import for DASH', () => { + const result = generateReactCreateCode({ ...baseReact, renderer: 'dash' }); + const code = result['MyPlayer.tsx']; + expect(code).toContain("import { DashVideo } from '@videojs/react/media/dash-video'"); + expect(code).toContain(''); + }); + + it('uses separate media import for Mux video', () => { + const result = generateReactCreateCode({ ...baseReact, renderer: 'mux-video' }); + const code = result['MyPlayer.tsx']; + expect(code).toContain("import { MuxVideo } from '@videojs/react/media/mux-video'"); + expect(code).toContain(''); + }); + + it('uses separate media import for Vimeo without playsInline (iframe)', () => { + const result = generateReactCreateCode({ ...baseReact, renderer: 'vimeo' }); + const code = result['MyPlayer.tsx']; + expect(code).toContain("import { VimeoVideo } from '@videojs/react/media/vimeo-video'"); + expect(code).toContain(''); + expect(code).not.toContain('playsInline'); + }); + it('uses audio features and components', () => { const opts: InstallationOptions = { ...baseReact, diff --git a/site/src/utils/installation/__tests__/detect-renderer.test.ts b/site/src/utils/installation/__tests__/detect-renderer.test.ts index 655ddee7..7b9781ac 100644 --- a/site/src/utils/installation/__tests__/detect-renderer.test.ts +++ b/site/src/utils/installation/__tests__/detect-renderer.test.ts @@ -3,16 +3,37 @@ import { articleFor, detectRenderer, isRendererValidForUseCase } from '../detect describe('detectRenderer', () => { describe('domain rules', () => { - // Domain-specific rules (YouTube, Vimeo, Mux, Spotify, Cloudflare, JW Player, Wistia) - // are commented out until their renderer elements are implemented. - // Mux stream.mux.com URLs with .m3u8 extension are detected as HLS via extension rules. - - it('detects stream.mux.com .m3u8 as HLS', () => { + it('detects stream.mux.com as Mux (video), taking precedence over the .m3u8 extension rule', () => { expect(detectRenderer('https://stream.mux.com/abc123.m3u8', 'default-video')).toEqual({ - renderer: 'hls', - label: 'HLS', + renderer: 'mux-video', + label: 'Mux', }); }); + + it('resolves a Mux host to mux-audio in an audio use case (falls through from the mux-video rule)', () => { + expect(detectRenderer('https://stream.mux.com/abc123.m3u8', 'default-audio')).toEqual({ + renderer: 'mux-audio', + label: 'Mux', + }); + }); + + it('detects vimeo.com as Vimeo', () => { + expect(detectRenderer('https://vimeo.com/648359100', 'default-video')).toEqual({ + renderer: 'vimeo', + label: 'Vimeo', + }); + }); + + it('detects player.vimeo.com as Vimeo', () => { + expect(detectRenderer('https://player.vimeo.com/video/648359100', 'default-video')).toEqual({ + renderer: 'vimeo', + label: 'Vimeo', + }); + }); + + it('returns null for a Vimeo URL in an audio use case (no audio fallthrough)', () => { + expect(detectRenderer('https://vimeo.com/648359100', 'default-audio')).toBeNull(); + }); }); describe('extension rules', () => { @@ -23,7 +44,16 @@ describe('detectRenderer', () => { }); }); - // .mpd detection commented out until DASH renderer is implemented + it('detects .mpd as DASH', () => { + expect(detectRenderer('https://example.com/video.mpd', 'default-video')).toEqual({ + renderer: 'dash', + label: 'DASH', + }); + }); + + it('returns null for .mpd in an audio use case', () => { + expect(detectRenderer('https://example.com/video.mpd', 'default-audio')).toBeNull(); + }); it('detects .mp4 as HTML5 Video', () => { expect(detectRenderer('https://example.com/video.mp4', 'default-video')).toEqual({ @@ -168,4 +198,19 @@ describe('isRendererValidForUseCase', () => { it('html5-video is not valid for background-video', () => { expect(isRendererValidForUseCase('html5-video', 'background-video')).toBe(false); }); + + it('dash and mux-video are valid for default-video', () => { + expect(isRendererValidForUseCase('dash', 'default-video')).toBe(true); + expect(isRendererValidForUseCase('mux-video', 'default-video')).toBe(true); + }); + + it('vimeo is valid for default-video but not default-audio', () => { + expect(isRendererValidForUseCase('vimeo', 'default-video')).toBe(true); + expect(isRendererValidForUseCase('vimeo', 'default-audio')).toBe(false); + }); + + it('mux-audio is valid for default-audio but not default-video', () => { + expect(isRendererValidForUseCase('mux-audio', 'default-audio')).toBe(true); + expect(isRendererValidForUseCase('mux-audio', 'default-video')).toBe(false); + }); }); diff --git a/site/src/utils/installation/__tests__/renderer-options.test.ts b/site/src/utils/installation/__tests__/renderer-options.test.ts new file mode 100644 index 00000000..e9705196 --- /dev/null +++ b/site/src/utils/installation/__tests__/renderer-options.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { buildOptions } from '../renderer-options'; + +describe('buildOptions', () => { + it('returns flat options in the configured order for default-video', () => { + expect(buildOptions('default-video')).toEqual([ + { value: 'html5-video', label: 'HTML5 Video' }, + { value: 'hls', label: 'HLS' }, + { value: 'dash', label: 'DASH' }, + { value: 'mux-video', label: 'Mux' }, + { value: 'vimeo', label: 'Vimeo' }, + ]); + }); + + it('returns flat options for default-audio', () => { + expect(buildOptions('default-audio')).toEqual([ + { value: 'html5-audio', label: 'HTML5 Audio' }, + { value: 'mux-audio', label: 'Mux' }, + ]); + }); +}); diff --git a/site/src/utils/installation/cdn-code.ts b/site/src/utils/installation/cdn-code.ts index fc2fd2f8..28623dab 100644 --- a/site/src/utils/installation/cdn-code.ts +++ b/site/src/utils/installation/cdn-code.ts @@ -1,4 +1,4 @@ -import type { Renderer, Skin, UseCase } from '@/utils/installation/types'; +import { getMediaSubpath, type Renderer, type Skin, type UseCase } from '@/utils/installation/types'; const CDN_BASE = 'https://cdn.jsdelivr.net/npm/@videojs/html/cdn'; @@ -10,36 +10,31 @@ function getCdnFileName(useCase: UseCase, skin: Skin): string { return skin; } -// Renderer → media subpath name, independent of whether a CDN build exists. -// Preset renderers (html5-video/audio, background-video) are covered by the -// preset bundle and have no separate media script, so they map to null. -function getMediaSubpath(renderer: Renderer): string | null { - const map: Partial> = { - hls: 'hlsjs-video', - }; - return map[renderer] ?? null; -} - // Whether a renderer can be installed via CDN, given the set of media subpaths // that ship a CDN build (from the cdn-media manifest). Preset renderers always -// can (no separate media script); a media renderer can only if its subpath is +// can (no separate media script); media renderers can only if their subpath is // in the manifest. export function rendererSupportsCdn(renderer: Renderer, cdnMediaSubpaths: readonly string[]): boolean { const subpath = getMediaSubpath(renderer); return subpath === null || cdnMediaSubpaths.includes(subpath); } -function getCdnMediaSubpath(renderer: Renderer): string | null { - return getMediaSubpath(renderer); -} - -export function generateCdnCode(useCase: UseCase, skin: Skin, renderer: Renderer): string { +export function generateCdnCode( + useCase: UseCase, + skin: Skin, + renderer: Renderer, + cdnMediaSubpaths: readonly string[] +): string { const name = getCdnFileName(useCase, skin); - const mediaSubpath = getCdnMediaSubpath(renderer); + const mediaSubpath = getMediaSubpath(renderer); const scriptLines = [``]; - if (mediaSubpath) { + // Emit a media script only when that media ships a CDN build, per the + // manifest. A media renderer whose subpath isn't in the manifest gets just the + // preset script; if it gains a CDN build later, the manifest carries it and + // this starts emitting automatically — no code change needed. + if (mediaSubpath !== null && cdnMediaSubpaths.includes(mediaSubpath)) { scriptLines.push(``); } diff --git a/site/src/utils/installation/codegen.ts b/site/src/utils/installation/codegen.ts index 830431fb..f9f38956 100644 --- a/site/src/utils/installation/codegen.ts +++ b/site/src/utils/installation/codegen.ts @@ -1,6 +1,12 @@ -import { VJS10_DEMO_VIDEO } from '@/consts'; +import { VJS10_DEMO_DASH, VJS10_DEMO_VIDEO, VJS10_DEMO_VIMEO } from '@/consts'; import { generateCdnCode } from '@/utils/installation/cdn-code'; -import type { InstallMethod, Renderer, Skin, UseCase } from '@/utils/installation/types'; +import { + getMediaSubpath, + type InstallMethod, + type Renderer, + type Skin, + type UseCase, +} from '@/utils/installation/types'; export interface InstallationOptions { framework: 'html' | 'react'; @@ -26,15 +32,36 @@ export function validateInstallationOptions(opts: InstallationOptions): Validati // --------------------------------------------------------------------------- function getDefaultSourceUrl(renderer: Renderer): string { - return renderer === 'hls' ? VJS10_DEMO_VIDEO.hls : VJS10_DEMO_VIDEO.mp4; + const map: Record = { + 'html5-video': VJS10_DEMO_VIDEO.mp4, + // Pre-existing quirk: the audio default points at a video .mp4. Fixing it + // needs a real audio asset we don't have — tracked as a follow-up. + 'html5-audio': VJS10_DEMO_VIDEO.mp4, + hls: VJS10_DEMO_VIDEO.hls, + 'background-video': VJS10_DEMO_VIDEO.mp4, + dash: VJS10_DEMO_DASH, + // Mux media take a stream.mux.com source; the demo HLS URL is already one. + 'mux-video': VJS10_DEMO_VIDEO.hls, + 'mux-audio': VJS10_DEMO_VIDEO.hls, + vimeo: VJS10_DEMO_VIMEO, + }; + return map[renderer]; } function resolveSourceUrl(sourceUrl: string, renderer: Renderer): string { return sourceUrl.trim() || getDefaultSourceUrl(renderer); } +// Whether the rendered media element takes the `playsinline` attribute. Vimeo +// renders an