fix(site): reset installation guide to implemented features (#707)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Darius Cepulis
2026-03-04 11:14:41 -06:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 4b282ff074
commit 2db6be75be
17 changed files with 211 additions and 463 deletions
@@ -9,22 +9,25 @@ function getSkinImportParts(skin: Skin): { group: string; skinFile: string } {
return { group: skin, skinFile: 'skin' };
}
// jsdelivr doesn't resolve Node package exports, so we use the full file path
const CDN_BASE = 'https://cdn.jsdelivr.net/npm/@videojs/html@next/dist/default/define';
function generateCdnCode(useCase: UseCase, skin: Skin): string {
if (useCase === 'background-video') {
return `<script>
import 'https://cdn.jsdelivr.net/npm/videojs/html/background/player.js';
import 'https://cdn.jsdelivr.net/npm/videojs/html/background/skin.js';
import '${CDN_BASE}/background/player.js';
import '${CDN_BASE}/background/skin.js';
</script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/videojs/html/background/skin.css" />`;
<link rel="stylesheet" href="${CDN_BASE}/background/skin.css" />`;
}
const { group, skinFile } = getSkinImportParts(skin);
return `<script>
import 'https://cdn.jsdelivr.net/npm/videojs/html/${group}/player.js';
import 'https://cdn.jsdelivr.net/npm/videojs/html/${group}/${skinFile}.js';
import '${CDN_BASE}/${group}/player.js';
import '${CDN_BASE}/${group}/${skinFile}.js';
</script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/videojs/html/${group}/${skinFile}.css" />`;
<link rel="stylesheet" href="${CDN_BASE}/${group}/${skinFile}.css" />`;
}
export default function HTMLCdnCodeBlock() {
@@ -48,16 +48,16 @@ export default function HTMLInstallTabs() {
<HTMLCdnCodeBlock />
</TabsPanel>
<TabsPanel value="npm">
<ClientCode code="npm install @videojs/html" lang="bash" />
<ClientCode code="npm install @videojs/html@next" lang="bash" />
</TabsPanel>
<TabsPanel value="pnpm">
<ClientCode code="pnpm add @videojs/html" lang="bash" />
<ClientCode code="pnpm add @videojs/html@next" lang="bash" />
</TabsPanel>
<TabsPanel value="yarn">
<ClientCode code="yarn add @videojs/html" lang="bash" />
<ClientCode code="yarn add @videojs/html@next" lang="bash" />
</TabsPanel>
<TabsPanel value="bun">
<ClientCode code="bun add @videojs/html" lang="bash" />
<ClientCode code="bun add @videojs/html@next" lang="bash" />
</TabsPanel>
</TabsRoot>
</div>
@@ -2,25 +2,25 @@ import { useStore } from '@nanostores/react';
import ClientCode from '@/components/Code/ClientCode';
import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs';
import type { Renderer, Skin, UseCase } from '@/stores/installation';
import { installMethod, muxPlaybackId, renderer, skin, sourceUrl, useCase } from '@/stores/installation';
import { installMethod, renderer, skin, sourceUrl, useCase } from '@/stores/installation';
function getRendererTag(renderer: Renderer): string {
const map: Record<Renderer, string> = {
'background-video': 'background-video',
cloudflare: 'cloudflare-video',
dash: 'dash-video',
// cloudflare: 'cloudflare-video',
// dash: 'dash-video',
hls: 'hls-video',
'html5-audio': 'audio',
'html5-video': 'video',
jwplayer: 'jwplayer-video',
'mux-audio': 'mux-audio',
'mux-background-video': 'mux-background-video',
'mux-video': 'mux-video',
// jwplayer: 'jwplayer-video',
// 'mux-audio': 'mux-audio',
// 'mux-background-video': 'mux-background-video',
// 'mux-video': 'mux-video',
// shaka: 'shaka-video',
spotify: 'spotify-audio',
vimeo: 'vimeo-video',
wistia: 'wistia-video',
youtube: 'youtube-video',
// spotify: 'spotify-audio',
// vimeo: 'vimeo-video',
// wistia: 'wistia-video',
// youtube: 'youtube-video',
};
return map[renderer];
}
@@ -42,34 +42,22 @@ function getSkinTag(useCase: UseCase, skin: Skin): string {
const map: Record<Skin, string> = {
video: 'video-skin',
audio: 'audio-skin',
'minimal-video': 'minimal-video-skin',
'minimal-audio': 'minimal-audio-skin',
'minimal-video': 'video-minimal-skin',
'minimal-audio': 'audio-minimal-skin',
};
return map[skin];
}
function getRendererElement(renderer: Renderer, playbackId: string | null, url: string): string {
function getRendererElement(renderer: Renderer, url: string): string {
const tag = getRendererTag(renderer);
// When renderer is a mux variant and we have a playback ID, use playback-id attribute
if ((renderer === 'mux-video' || renderer === 'mux-audio' || renderer === 'mux-background-video') && playbackId) {
return `<${tag} playback-id="${playbackId}"></${tag}>`;
}
const src = url.trim() || '...';
return `<${tag} src="${src}"></${tag}>`;
}
function generateHTMLCode(
useCase: UseCase,
skin: Skin,
renderer: Renderer,
playbackId: string | null,
url: string
): string {
function generateHTMLCode(useCase: UseCase, skin: Skin, renderer: Renderer, url: string): string {
const providerTag = getProviderTag(useCase);
const skinTag = getSkinTag(useCase, skin);
const rendererElement = getRendererElement(renderer, playbackId, url);
const rendererElement = getRendererElement(renderer, url);
return `<!--
The PlayerProvider passes state between the UI components
@@ -99,24 +87,37 @@ function getSkinImportParts(skin: Skin): { group: string; skinFile: string } {
return { group: skin, skinFile: 'skin' };
}
function generateJS(useCase: UseCase, skin: Skin): string {
function getMediaImportSubpath(renderer: Renderer): string | null {
const map: Partial<Record<Renderer, string>> = {
hls: 'hls-video',
// 'mux-audio': 'mux-audio',
// 'mux-background-video': 'mux-background-video',
// 'mux-video': 'mux-video',
};
return map[renderer] ?? null;
}
function generateJS(useCase: UseCase, skin: Skin, renderer: Renderer): string {
if (useCase === 'background-video') {
const mediaSubpath = getMediaImportSubpath(renderer);
const mediaImport = mediaSubpath ? `\nimport '@videojs/html/media/${mediaSubpath}';` : '';
return `import '@videojs/html/background/player';
import '@videojs/html/background/skin';
import '@videojs/html/background/skin.css';
import '@videojs/html/background/video';`;
import '@videojs/html/background/video';${mediaImport}`;
}
const { group, skinFile } = getSkinImportParts(skin);
const mediaSubpath = getMediaImportSubpath(renderer);
const mediaImport = mediaSubpath ? `\nimport '@videojs/html/media/${mediaSubpath}';` : '';
return `import '@videojs/html/${group}/player';
import '@videojs/html/${group}/${skinFile}';
import '@videojs/html/${group}/${skinFile}.css';`;
import '@videojs/html/${group}/${skinFile}.css';${mediaImport}`;
}
export default function HTMLUsageCodeBlock() {
const $useCase = useStore(useCase);
const $skin = useStore(skin);
const $renderer = useStore(renderer);
const $muxPlaybackId = useStore(muxPlaybackId);
const $installMethod = useStore(installMethod);
const $sourceUrl = useStore(sourceUrl);
@@ -130,7 +131,7 @@ export default function HTMLUsageCodeBlock() {
</Tab>
</TabsList>
<TabsPanel value="javascript" initial>
<ClientCode code={generateJS($useCase, $skin)} lang="javascript" />
<ClientCode code={generateJS($useCase, $skin, $renderer)} lang="javascript" />
</TabsPanel>
</TabsRoot>
)}
@@ -141,7 +142,7 @@ export default function HTMLUsageCodeBlock() {
</Tab>
</TabsList>
<TabsPanel value="html" initial>
<ClientCode code={generateHTMLCode($useCase, $skin, $renderer, $muxPlaybackId, $sourceUrl)} lang="html" />
<ClientCode code={generateHTMLCode($useCase, $skin, $renderer, $sourceUrl)} lang="html" />
</TabsPanel>
</TabsRoot>
</>
@@ -2,25 +2,25 @@ import { useStore } from '@nanostores/react';
import ClientCode from '@/components/Code/ClientCode';
import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs';
import type { Renderer, Skin, UseCase } from '@/stores/installation';
import { muxPlaybackId, renderer, skin, useCase } from '@/stores/installation';
import { renderer, skin, useCase } from '@/stores/installation';
function getRendererComponent(renderer: Renderer): string {
const map: Record<Renderer, string> = {
'background-video': 'BackgroundVideo',
cloudflare: 'CloudflareVideo',
dash: 'DashVideo',
// cloudflare: 'CloudflareVideo',
// dash: 'DashVideo',
hls: 'HlsVideo',
'html5-audio': 'Audio',
'html5-video': 'Video',
jwplayer: 'JwplayerVideo',
'mux-audio': 'MuxAudio',
'mux-background-video': 'MuxBackgroundVideo',
'mux-video': 'MuxVideo',
// shaka: 'ShakaVideo', this one probably adds more confusion than help at this level
spotify: 'SpotifyAudio',
vimeo: 'VimeoVideo',
wistia: 'WistiaVideo',
youtube: 'YoutubeVideo',
// jwplayer: 'JwplayerVideo',
// mux-audio: 'MuxAudio',
// mux-background-video: 'MuxBackgroundVideo',
// mux-video: 'MuxVideo',
// shaka: 'ShakaVideo',
// spotify: 'SpotifyAudio',
// vimeo: 'VimeoVideo',
// wistia: 'WistiaVideo',
// youtube: 'YoutubeVideo',
};
return map[renderer];
}
@@ -43,9 +43,9 @@ function getSkinImportParts(skin: Skin): { group: string; skinFile: string } {
function getUseCaseFeatures(useCase: UseCase): string {
const map: Record<UseCase, string> = {
'default-video': 'video',
'default-audio': 'audio',
'background-video': 'background',
'default-video': 'videoFeatures',
'default-audio': 'audioFeatures',
'background-video': 'backgroundFeatures',
};
return map[useCase];
}
@@ -56,22 +56,22 @@ function isPresetRenderer(renderer: Renderer): boolean {
function getRendererMediaSubpath(renderer: Renderer): string {
const map: Partial<Record<Renderer, string>> = {
cloudflare: 'cloudflare-video',
dash: 'dash-video',
// cloudflare: 'cloudflare-video',
// dash: 'dash-video',
hls: 'hls-video',
jwplayer: 'jwplayer-video',
'mux-audio': 'mux-audio',
'mux-background-video': 'mux-background-video',
'mux-video': 'mux-video',
spotify: 'spotify-audio',
vimeo: 'vimeo-video',
wistia: 'wistia-video',
youtube: 'youtube-video',
// jwplayer: 'jwplayer-video',
// 'mux-audio': 'mux-audio',
// 'mux-background-video': 'mux-background-video',
// 'mux-video': 'mux-video',
// spotify: 'spotify-audio',
// vimeo: 'vimeo-video',
// wistia: 'wistia-video',
// youtube: 'youtube-video',
};
return map[renderer] ?? renderer;
}
function generateReactCode(useCase: UseCase, skin: Skin, renderer: Renderer, playbackId: string | null): string {
function generateReactCode(useCase: UseCase, skin: Skin, renderer: Renderer): string {
const rendererComponent = getRendererComponent(renderer);
const featureType = getUseCaseFeatures(useCase);
@@ -97,27 +97,21 @@ function generateReactCode(useCase: UseCase, skin: Skin, renderer: Renderer, pla
mediaImport = `import { ${rendererComponent} } from '@videojs/react/media/${getRendererMediaSubpath(renderer)}';`;
}
// Determine props based on renderer + playbackId
const isMuxWithPlaybackId =
(renderer === 'mux-video' || renderer === 'mux-audio' || renderer === 'mux-background-video') && playbackId;
const propsInterface = isMuxWithPlaybackId
? 'interface MyPlayerProps {\n playbackId: string;\n}'
: 'interface MyPlayerProps {\n src: string;\n}';
const destructuredProp = isMuxWithPlaybackId ? 'playbackId' : 'src';
const rendererJsx = isMuxWithPlaybackId
? `<${rendererComponent} playbackId={playbackId} />`
: `<${rendererComponent} src={src} />`;
// Determine props — mux variants use src with stream.mux.com URL
const propsInterface = 'interface MyPlayerProps {\n src: string;\n}';
const destructuredProp = 'src';
const rendererJsx = `<${rendererComponent} src={src} />`;
const imports = [
`import '${skinCssImport}';`,
`import { createPlayer, features } from '@videojs/react';`,
`import { createPlayer, ${featureType} } from '@videojs/react';`,
presetImport,
...(mediaImport ? [mediaImport] : []),
].join('\n');
return `${imports}
const Player = createPlayer({ features: features.${featureType} });
const Player = createPlayer({ features: ${featureType} });
${propsInterface}
@@ -136,7 +130,6 @@ export default function ReactCreateCodeBlock() {
const $useCase = useStore(useCase);
const $skin = useStore(skin);
const $renderer = useStore(renderer);
const $muxPlaybackId = useStore(muxPlaybackId);
return (
<TabsRoot maxWidth={false}>
@@ -146,7 +139,7 @@ export default function ReactCreateCodeBlock() {
</Tab>
</TabsList>
<TabsPanel value="react" initial>
<ClientCode code={generateReactCode($useCase, $skin, $renderer, $muxPlaybackId)} lang="tsx" />
<ClientCode code={generateReactCode($useCase, $skin, $renderer)} lang="tsx" />
</TabsPanel>
</TabsRoot>
);
@@ -1,21 +1,10 @@
import { useStore } from '@nanostores/react';
import ClientCode from '@/components/Code/ClientCode';
import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs';
import type { Renderer } from '@/stores/installation';
import { muxPlaybackId, renderer, sourceUrl } from '@/stores/installation';
import { renderer, sourceUrl } from '@/stores/installation';
function generateUsageCode(renderer: Renderer, playbackId: string | null, url: string): string {
const isMuxWithPlaybackId =
(renderer === 'mux-video' || renderer === 'mux-audio' || renderer === 'mux-background-video') && playbackId;
let playerProp: string;
if (isMuxWithPlaybackId) {
playerProp = `playbackId="${playbackId}"`;
} else if (url.trim()) {
playerProp = `src="${url.trim()}"`;
} else {
playerProp = 'src="https://example.com/video.mp4"';
}
function generateUsageCode(url: string): string {
const playerProp = url.trim() ? `src="${url.trim()}"` : 'src="https://example.com/video.mp4"';
return `import { MyPlayer } from '../components/player';
@@ -30,8 +19,7 @@ export const HomePage = () => {
}
export default function ReactUsageCodeBlock() {
const $renderer = useStore(renderer);
const $muxPlaybackId = useStore(muxPlaybackId);
useStore(renderer);
const $sourceUrl = useStore(sourceUrl);
return (
@@ -42,7 +30,7 @@ export default function ReactUsageCodeBlock() {
</Tab>
</TabsList>
<TabsPanel value="react" initial>
<ClientCode code={generateUsageCode($renderer, $muxPlaybackId, $sourceUrl)} lang="tsx" />
<ClientCode code={generateUsageCode($sourceUrl)} lang="tsx" />
</TabsPanel>
</TabsRoot>
);
@@ -2,24 +2,24 @@ import { useStore } from '@nanostores/react';
import { useEffect } from 'react';
import { Select, type SelectOption } from '@/components/Select';
import type { Renderer, UseCase } from '@/stores/installation';
import { muxPlaybackId, renderer, sourceUrl, useCase, VALID_RENDERERS } from '@/stores/installation';
import { articleFor, detectRenderer, extractMuxPlaybackId } from '@/utils/installation/detect-renderer';
import { renderer, sourceUrl, useCase, VALID_RENDERERS } from '@/stores/installation';
import { articleFor, detectRenderer } from '@/utils/installation/detect-renderer';
const RENDERER_LABELS: Record<Renderer, string> = {
'background-video': 'Background Video',
cloudflare: 'Cloudflare',
dash: 'DASH',
// 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',
// 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<Renderer>[] {
@@ -36,16 +36,15 @@ export default function RendererSelect() {
const options = buildOptions($useCase);
const detection = detectRenderer($sourceUrl, $useCase);
const detectedRenderer = detection?.renderer ?? null;
// Auto-select renderer when detection or use case changes
// Auto-select renderer when the detected renderer or use case changes.
// Uses the primitive `detectedRenderer` string instead of the `detection`
// object to avoid re-firing on every render (new object reference each time),
// which would override manual dropdown selection.
useEffect(() => {
if (detection) {
renderer.set(detection.renderer);
const playbackId = extractMuxPlaybackId($sourceUrl);
if (playbackId) {
muxPlaybackId.set(playbackId);
}
if (detectedRenderer) {
renderer.set(detectedRenderer);
} else {
// No valid detection — ensure current renderer is valid for use case
const current = renderer.get();
@@ -54,7 +53,7 @@ export default function RendererSelect() {
renderer.set(validRenderers[0]!);
}
}
}, [detection, $sourceUrl, $useCase]);
}, [detectedRenderer, $useCase]);
const showDetectionMatch = $sourceUrl.trim() && detection && detection.renderer === $renderer;
const showDetectionSuggestion = $sourceUrl.trim() && detection && detection.renderer !== $renderer;
@@ -1,6 +0,0 @@
---
title: 'Under Construction'
description: 'Video.js 10 docs are coming soon!'
---
Please pardon our dust as we build this all out.
+8 -14
View File
@@ -18,29 +18,18 @@ Video.js v10 is a complete rebuild from the ground up, designed to be more modul
- Reserved only for urgent API-breaking changes that cannot be avoided
- Acts as a stopgap between v8 and the new v10 rebuild
## October 30, 2025: Technical preview
**First public look** at Video.js v10, rebuilt from the ground up.
**Goal:** Get feedback from early adopters and plugin authors.
**What to expect:**
- Limited feature set
- Not production-ready
- Existing plugins and integrations will not work with v10
- Developers encouraged to check it out and give feedback
## February 2026: v10 Beta Release
## March 2026: v10 Beta Release
Wider testing release to the community.
**Core feature parity target:**
- Basic control set (play, pause, seek, volume, fullscreen)
- Adaptive streaming support via existing community engines
- No plugin migrations yet, without plugin author contribution
**What to expect:**
- Close to stable, but API changes are still possible
- Intended for experimental adoption in real-world projects
- Developers encouraged to try it out and report issues
@@ -50,18 +39,21 @@ Wider testing release to the community.
**Stable release** of Video.js v10
**Feature targets:**
- Core feature parity with v8 in terms of fundamentals
- Feature parity with Media Chrome, Vidstack, Plyr, and Mux Player
- Community encouraged to begin full reintegration projects
- Plugin ecosystem migration begins in earnest
**Engine development focus:**
- Lightweight adaptive streaming
- MoQ (Media over QUIC) support
## Late-2026: Feature parity with v8 Contrib
**Extended feature parity** with Video.js "contrib" plugins:
- Ads integration
- Analytics support
- Further community plugin migration dependent on contributions
@@ -69,10 +61,12 @@ Wider testing release to the community.
## End-of-life plan for Video.js v8
### Mid-2026
- v8/v9 moved to "video.js-legacy" repository and package
- Hosted CDN copies will remain available
### 2027 and beyond
- Security patches only
- Community contributions de-prioritized
@@ -74,22 +74,22 @@ Video.js supports a wide range of file types and hosting services. It's easy to
</TabsList>
<TabsPanel client:idle value="npm" initial>
```bash
npm install @videojs/react
npm install @videojs/react@next
```
</TabsPanel>
<TabsPanel client:idle value="pnpm">
```bash
pnpm add @videojs/react
pnpm add @videojs/react@next
```
</TabsPanel>
<TabsPanel client:idle value="yarn">
```bash
yarn add @videojs/react
yarn add @videojs/react@next
```
</TabsPanel>
<TabsPanel client:idle value="bun">
```bash
bun add @videojs/react
bun add @videojs/react@next
```
</TabsPanel>
</TabsRoot>
+12 -8
View File
@@ -18,14 +18,18 @@ export const sidebar: Sidebar = [
{ slug: 'concepts/v10-roadmap', sidebarLabel: 'Roadmap' },
],
},
{
sidebarLabel: 'Concepts',
contents: [{ slug: 'concepts/architecture' }, { slug: 'concepts/skins' }, { slug: 'concepts/ui-components' }],
},
{
sidebarLabel: 'How to',
contents: [{ slug: 'how-to/customize-skins' }],
},
// {
// sidebarLabel: 'Concepts',
// contents: [
// { slug: 'concepts/architecture' },
// { slug: 'concepts/skins' },
// { slug: 'concepts/ui-components' }
// ],
// },
// {
// sidebarLabel: 'How to',
// contents: [{ slug: 'how-to/customize-skins' }],
// },
{
sidebarLabel: 'Components',
contents: [
+18 -15
View File
@@ -2,20 +2,20 @@ import { atom } from 'nanostores';
export type Renderer =
| 'background-video'
| 'cloudflare'
| 'dash'
// | 'cloudflare'
// | 'dash'
| 'hls'
| 'html5-audio'
| 'html5-video'
| 'jwplayer'
| 'mux-audio'
| 'mux-background-video'
| 'mux-video'
// | 'shaka'
| 'spotify'
| 'vimeo'
| 'wistia'
| 'youtube';
| 'html5-video';
// | 'jwplayer'
// | 'mux-audio'
// | 'mux-background-video'
// | 'mux-video'
// | 'shaka'
// | 'spotify'
// | 'vimeo'
// | 'wistia'
// | 'youtube'
export type Skin = 'video' | 'audio' | 'minimal-video' | 'minimal-audio';
@@ -34,7 +34,10 @@ export const installMethod = atom<InstallMethod>('cdn');
export const muxPlaybackId = atom<string | null>(null);
export const VALID_RENDERERS: Record<UseCase, Renderer[]> = {
'default-video': ['html5-video', 'cloudflare', 'dash', 'hls', 'jwplayer', 'mux-video', 'vimeo', 'wistia', 'youtube'],
'default-audio': ['html5-audio', 'mux-audio', 'spotify'],
'background-video': ['background-video', 'mux-background-video'],
'default-video': [
'html5-video',
/* 'cloudflare', 'dash', */ 'hls' /* , 'jwplayer', 'mux-video', 'vimeo', 'wistia', 'youtube' */,
],
'default-audio': ['html5-audio' /* , 'mux-audio', 'spotify' */],
'background-video': ['background-video' /* , 'mux-background-video' */],
};
@@ -1,124 +1,16 @@
import { describe, expect, it } from 'vitest';
import { articleFor, detectRenderer, extractMuxPlaybackId, isRendererValidForUseCase } from '../detect-renderer';
import { articleFor, detectRenderer, isRendererValidForUseCase } from '../detect-renderer';
describe('detectRenderer', () => {
describe('domain rules', () => {
it('detects youtube.com', () => {
expect(detectRenderer('https://www.youtube.com/watch?v=abc123', 'default-video')).toEqual({
renderer: 'youtube',
label: 'YouTube',
});
});
// 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 youtu.be', () => {
expect(detectRenderer('https://youtu.be/abc123', 'default-video')).toEqual({
renderer: 'youtube',
label: 'YouTube',
});
});
it('detects m.youtube.com', () => {
expect(detectRenderer('https://m.youtube.com/watch?v=abc123', 'default-video')).toEqual({
renderer: 'youtube',
label: 'YouTube',
});
});
it('detects vimeo.com', () => {
expect(detectRenderer('https://vimeo.com/123456', 'default-video')).toEqual({
renderer: 'vimeo',
label: 'Vimeo',
});
});
it('detects player.vimeo.com', () => {
expect(detectRenderer('https://player.vimeo.com/video/123456', 'default-video')).toEqual({
renderer: 'vimeo',
label: 'Vimeo',
});
});
it('detects stream.mux.com for default-video', () => {
it('detects stream.mux.com .m3u8 as HLS', () => {
expect(detectRenderer('https://stream.mux.com/abc123.m3u8', 'default-video')).toEqual({
renderer: 'mux-video',
label: 'Mux',
});
});
it('detects stream.mux.com for default-audio', () => {
expect(detectRenderer('https://stream.mux.com/abc123.m3u8', 'default-audio')).toEqual({
renderer: 'mux-audio',
label: 'Mux',
});
});
it('detects stream.mux.com for background-video', () => {
expect(detectRenderer('https://stream.mux.com/abc123.m3u8', 'background-video')).toEqual({
renderer: 'mux-background-video',
label: 'Mux',
});
});
it('detects open.spotify.com', () => {
expect(detectRenderer('https://open.spotify.com/track/abc123', 'default-audio')).toEqual({
renderer: 'spotify',
label: 'Spotify',
});
});
it('detects watch.videodelivery.net', () => {
expect(detectRenderer('https://watch.videodelivery.net/abc123', 'default-video')).toEqual({
renderer: 'cloudflare',
label: 'Cloudflare',
});
});
it('detects videodelivery.net', () => {
expect(detectRenderer('https://videodelivery.net/abc123', 'default-video')).toEqual({
renderer: 'cloudflare',
label: 'Cloudflare',
});
});
it('detects cloudflarestream.com', () => {
expect(detectRenderer('https://cloudflarestream.com/abc123/manifest/video.m3u8', 'default-video')).toEqual({
renderer: 'cloudflare',
label: 'Cloudflare',
});
});
it('detects cdn.jwplayer.com', () => {
expect(detectRenderer('https://cdn.jwplayer.com/players/abc123.html', 'default-video')).toEqual({
renderer: 'jwplayer',
label: 'JW Player',
});
});
it('detects content.jwplatform.com', () => {
expect(detectRenderer('https://content.jwplatform.com/videos/abc123.mp4', 'default-video')).toEqual({
renderer: 'jwplayer',
label: 'JW Player',
});
});
it('detects fast.wistia.com', () => {
expect(detectRenderer('https://fast.wistia.com/medias/abc123', 'default-video')).toEqual({
renderer: 'wistia',
label: 'Wistia',
});
});
it('detects fast.wistia.net', () => {
expect(detectRenderer('https://fast.wistia.net/medias/abc123', 'default-video')).toEqual({
renderer: 'wistia',
label: 'Wistia',
});
});
it('detects *.wistia.com', () => {
expect(detectRenderer('https://mycompany.wistia.com/medias/abc123', 'default-video')).toEqual({
renderer: 'wistia',
label: 'Wistia',
renderer: 'hls',
label: 'HLS',
});
});
});
@@ -131,12 +23,7 @@ describe('detectRenderer', () => {
});
});
it('detects .mpd as DASH', () => {
expect(detectRenderer('https://example.com/video.mpd', 'default-video')).toEqual({
renderer: 'dash',
label: 'DASH',
});
});
// .mpd detection commented out until DASH renderer is implemented
it('detects .mp4 as HTML5 Video', () => {
expect(detectRenderer('https://example.com/video.mp4', 'default-video')).toEqual({
@@ -209,30 +96,7 @@ describe('detectRenderer', () => {
});
});
describe('domain priority over extension', () => {
it('stream.mux.com with .m3u8 detects as Mux, not HLS', () => {
expect(detectRenderer('https://stream.mux.com/abc123.m3u8', 'default-video')).toEqual({
renderer: 'mux-video',
label: 'Mux',
});
});
it('content.jwplatform.com with .mp4 detects as JW Player, not HTML5 Video', () => {
expect(detectRenderer('https://content.jwplatform.com/videos/abc.mp4', 'default-video')).toEqual({
renderer: 'jwplayer',
label: 'JW Player',
});
});
});
describe('URL without protocol', () => {
it('auto-prepends https://', () => {
expect(detectRenderer('youtube.com/watch?v=abc', 'default-video')).toEqual({
renderer: 'youtube',
label: 'YouTube',
});
});
it('auto-prepends https:// for extension-based detection', () => {
expect(detectRenderer('example.com/video.mp4', 'default-video')).toEqual({
renderer: 'html5-video',
@@ -260,18 +124,6 @@ describe('detectRenderer', () => {
});
describe('use-case filtering', () => {
it('returns null for YouTube with audio use case', () => {
expect(detectRenderer('https://www.youtube.com/watch?v=abc', 'default-audio')).toBeNull();
});
it('returns null for YouTube with background-video use case', () => {
expect(detectRenderer('https://www.youtube.com/watch?v=abc', 'background-video')).toBeNull();
});
it('returns null for Spotify with default-video use case', () => {
expect(detectRenderer('https://open.spotify.com/track/abc', 'default-video')).toBeNull();
});
it('returns null for .mp3 with default-video use case', () => {
expect(detectRenderer('https://example.com/audio.mp3', 'default-video')).toBeNull();
});
@@ -282,36 +134,6 @@ describe('detectRenderer', () => {
});
});
describe('extractMuxPlaybackId', () => {
it('extracts playback ID from stream.mux.com URL with .m3u8', () => {
expect(extractMuxPlaybackId('https://stream.mux.com/abc123.m3u8')).toBe('abc123');
});
it('extracts playback ID from stream.mux.com URL without extension', () => {
expect(extractMuxPlaybackId('https://stream.mux.com/abc123')).toBe('abc123');
});
it('returns null for non-Mux URL', () => {
expect(extractMuxPlaybackId('https://example.com/abc123.m3u8')).toBeNull();
});
it('returns null for mux.com (not stream.mux.com)', () => {
expect(extractMuxPlaybackId('https://mux.com/abc123')).toBeNull();
});
it('returns null for empty URL', () => {
expect(extractMuxPlaybackId('')).toBeNull();
});
it('returns null for invalid URL', () => {
expect(extractMuxPlaybackId('not a url')).toBeNull();
});
it('returns null for stream.mux.com with no path segment', () => {
expect(extractMuxPlaybackId('https://stream.mux.com/')).toBeNull();
});
});
describe('articleFor', () => {
it('returns "an" for hls', () => {
expect(articleFor('hls')).toBe('an');
@@ -324,26 +146,6 @@ describe('articleFor', () => {
it('returns "an" for html5-audio', () => {
expect(articleFor('html5-audio')).toBe('an');
});
it('returns "a" for youtube', () => {
expect(articleFor('youtube')).toBe('a');
});
it('returns "a" for vimeo', () => {
expect(articleFor('vimeo')).toBe('a');
});
it('returns "a" for mux-video', () => {
expect(articleFor('mux-video')).toBe('a');
});
it('returns "a" for dash', () => {
expect(articleFor('dash')).toBe('a');
});
it('returns "a" for jwplayer', () => {
expect(articleFor('jwplayer')).toBe('a');
});
});
describe('isRendererValidForUseCase', () => {
@@ -351,22 +153,10 @@ describe('isRendererValidForUseCase', () => {
expect(isRendererValidForUseCase('html5-video', 'default-video')).toBe(true);
});
it('youtube is valid for default-video', () => {
expect(isRendererValidForUseCase('youtube', 'default-video')).toBe(true);
});
it('youtube is not valid for default-audio', () => {
expect(isRendererValidForUseCase('youtube', 'default-audio')).toBe(false);
});
it('html5-audio is valid for default-audio', () => {
expect(isRendererValidForUseCase('html5-audio', 'default-audio')).toBe(true);
});
it('spotify is valid for default-audio', () => {
expect(isRendererValidForUseCase('spotify', 'default-audio')).toBe(true);
});
it('background-video is valid for background-video', () => {
expect(isRendererValidForUseCase('background-video', 'background-video')).toBe(true);
});
+55 -76
View File
@@ -7,45 +7,45 @@ export interface DetectionResult {
}
const DOMAIN_RULES: Array<{ match: (hostname: string) => boolean; renderer: Renderer; label: string }> = [
{
match: (h) => h === 'youtube.com' || h === 'www.youtube.com' || h === 'youtu.be' || h === 'm.youtube.com',
renderer: 'youtube',
label: 'YouTube',
},
{
match: (h) => h === 'vimeo.com' || h === 'www.vimeo.com' || h === 'player.vimeo.com',
renderer: 'vimeo',
label: 'Vimeo',
},
{
match: (h) => h === 'stream.mux.com' || h === 'mux.com' || h === 'www.mux.com',
renderer: 'mux-video',
label: 'Mux',
},
{
match: (h) => h === 'open.spotify.com',
renderer: 'spotify',
label: 'Spotify',
},
{
match: (h) =>
h === 'watch.videodelivery.net' ||
h === 'videodelivery.net' ||
h === 'cloudflarestream.com' ||
h === 'www.cloudflarestream.com',
renderer: 'cloudflare',
label: 'Cloudflare',
},
{
match: (h) => h === 'cdn.jwplayer.com' || h === 'content.jwplatform.com',
renderer: 'jwplayer',
label: 'JW Player',
},
{
match: (h) => h === 'fast.wistia.com' || h === 'fast.wistia.net' || h.endsWith('.wistia.com'),
renderer: 'wistia',
label: 'Wistia',
},
// {
// match: (h) => h === 'youtube.com' || h === 'www.youtube.com' || h === 'youtu.be' || h === 'm.youtube.com',
// renderer: 'youtube',
// label: 'YouTube',
// },
// {
// match: (h) => h === 'vimeo.com' || h === 'www.vimeo.com' || h === 'player.vimeo.com',
// renderer: 'vimeo',
// label: 'Vimeo',
// },
// {
// match: (h) => h === 'stream.mux.com' || h === 'mux.com' || h === 'www.mux.com',
// renderer: 'mux-video',
// label: 'Mux',
// },
// {
// match: (h) => h === 'open.spotify.com',
// renderer: 'spotify',
// label: 'Spotify',
// },
// {
// match: (h) =>
// h === 'watch.videodelivery.net' ||
// h === 'videodelivery.net' ||
// h === 'cloudflarestream.com' ||
// h === 'www.cloudflarestream.com',
// renderer: 'cloudflare',
// label: 'Cloudflare',
// },
// {
// match: (h) => h === 'cdn.jwplayer.com' || h === 'content.jwplatform.com',
// renderer: 'jwplayer',
// label: 'JW Player',
// },
// {
// match: (h) => h === 'fast.wistia.com' || h === 'fast.wistia.net' || h.endsWith('.wistia.com'),
// renderer: 'wistia',
// label: 'Wistia',
// },
];
const VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.mov', '.ogv']);
@@ -70,13 +70,6 @@ function getExtension(pathname: string): string {
return clean.slice(dot).toLowerCase();
}
function resolveRendererForUseCase(renderer: Renderer, useCase: UseCase): Renderer {
if (renderer !== 'mux-video') return renderer;
if (useCase === 'default-audio') return 'mux-audio';
if (useCase === 'background-video') return 'mux-background-video';
return 'mux-video';
}
export function detectRenderer(url: string, useCase: UseCase): DetectionResult | null {
const trimmed = url.trim();
if (!trimmed) return null;
@@ -87,9 +80,8 @@ export function detectRenderer(url: string, useCase: UseCase): DetectionResult |
// Check domain rules first
for (const rule of DOMAIN_RULES) {
if (rule.match(parsed.hostname)) {
const resolved = resolveRendererForUseCase(rule.renderer, useCase);
if (!isRendererValidForUseCase(resolved, useCase)) return null;
return { renderer: resolved, label: rule.label };
if (!isRendererValidForUseCase(rule.renderer, useCase)) return null;
return { renderer: rule.renderer, label: rule.label };
}
}
@@ -101,10 +93,10 @@ export function detectRenderer(url: string, useCase: UseCase): DetectionResult |
return { renderer: 'hls', label: 'HLS' };
}
if (ext === '.mpd') {
if (!isRendererValidForUseCase('dash', useCase)) return null;
return { renderer: 'dash', label: 'DASH' };
}
// if (ext === '.mpd') {
// if (!isRendererValidForUseCase('dash', useCase)) return null;
// return { renderer: 'dash', label: 'DASH' };
// }
if (VIDEO_EXTENSIONS.has(ext)) {
if (!isRendererValidForUseCase('html5-video', useCase)) return null;
@@ -119,38 +111,25 @@ export function detectRenderer(url: string, useCase: UseCase): DetectionResult |
return null;
}
export function extractMuxPlaybackId(url: string): string | null {
const parsed = parseUrl(url);
if (!parsed) return null;
if (parsed.hostname !== 'stream.mux.com') return null;
// pathname is /{PLAYBACK_ID} or /{PLAYBACK_ID}.m3u8
const segment = parsed.pathname.slice(1); // remove leading /
if (!segment) return null;
return segment.replace(/\.m3u8$/, '') || null;
}
export function isRendererValidForUseCase(renderer: Renderer, useCase: UseCase): boolean {
return VALID_RENDERERS[useCase].includes(renderer);
}
const RENDERER_ARTICLES: Record<Renderer, 'a' | 'an'> = {
'background-video': 'a',
cloudflare: 'a',
dash: 'a',
// cloudflare: 'a',
// dash: 'a',
hls: 'an',
'html5-audio': 'an',
'html5-video': 'an',
jwplayer: 'a',
'mux-audio': 'a',
'mux-background-video': 'a',
'mux-video': 'a',
spotify: 'a',
vimeo: 'a',
wistia: 'a',
youtube: 'a',
// jwplayer: 'a',
// 'mux-audio': 'a',
// 'mux-background-video': 'a',
// 'mux-video': 'a',
// spotify: 'a',
// vimeo: 'a',
// wistia: 'a',
// youtube: 'a',
};
export function articleFor(renderer: Renderer): 'a' | 'an' {