fix(html): extended media not working over cdn (#1019)

This commit is contained in:
rahim
2026-03-19 02:12:13 -07:00
committed by GitHub
parent 636ccd45da
commit cc04cc99a5
10 changed files with 272 additions and 51 deletions
+12 -1
View File
@@ -13,7 +13,8 @@
"module": "dist/default/index.js",
"types": "dist/dev/index.d.ts",
"sideEffects": [
"./dist/*/define/**/*.js"
"./dist/*/define/**/*.js",
"./cdn/**/*.js"
],
"files": [
"dist",
@@ -73,6 +74,16 @@
"development": "./dist/dev/define/media/*.js",
"default": "./dist/default/define/media/*.js"
},
"./cdn/media/*": {
"types": "./cdn/media/*.dev.d.ts",
"development": "./cdn/media/*.dev.js",
"default": "./cdn/media/*.js"
},
"./cdn/*": {
"types": "./cdn/*.dev.d.ts",
"development": "./cdn/*.dev.js",
"default": "./cdn/*.js"
},
"./*.css": "./dist/default/define/*.css",
"./*": {
"types": "./dist/dev/define/*.d.ts",
+1
View File
@@ -1,2 +1,3 @@
import '../define/background/player';
import '../define/background/skin';
import '../define/background/video';
+68 -39
View File
@@ -1,3 +1,4 @@
import { readdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { UserConfig } from 'tsdown';
@@ -12,7 +13,7 @@ const skinsDir = resolve(dirname(fileURLToPath(import.meta.url)), '../skins/src'
const buildModes: BuildMode[] = ['dev', 'prod'];
const presets = ['video', 'video-minimal', 'audio', 'audio-minimal', 'background'];
const media = ['hls-video', 'simple-hls-video'];
const media = ['hls-video', 'simple-hls-video', 'dash-video'];
const entries = [
...presets.map((name) => ({ src: `src/cdn/${name}.ts`, name })),
@@ -20,50 +21,78 @@ const entries = [
];
/**
* One config per entry per mode → each output is fully self-contained.
* Multiple entries in a single config causes rolldown to code-split shared
* modules into chunks, which breaks the single-file CDN bundle requirement.
* Rolldown plugin that generates empty `.d.ts` stubs for dev CDN entry points.
* CDN entries are side-effect-only modules with no exports — the stubs let
* TypeScript resolve `import '@videojs/html/cdn/...'` without errors.
*/
function dtsStubsPlugin(outDir: string) {
function generate(dir: string) {
for (const file of readdirSync(dir, { withFileTypes: true })) {
if (file.isDirectory()) {
generate(resolve(dir, file.name));
} else if (file.name.endsWith('.dev.js') && !file.name.endsWith('.dev.js.map')) {
writeFileSync(resolve(dir, file.name.replace('.dev.js', '.dev.d.ts')), 'export {};\n');
}
}
}
return {
name: 'cdn-dts-stubs',
writeBundle() {
generate(outDir);
},
};
}
/**
* One config per mode with all entries grouped together.
* This lets rolldown extract shared modules (store, element, core, hls.js, etc.)
* into shared chunks instead of duplicating them across every bundle.
* The ES module loader handles chunk deduplication transparently.
*/
const configs: UserConfig[] = [];
const outDir = 'cdn';
for (const mode of buildModes) {
for (const { src, name } of entries) {
const outName = mode === 'dev' ? `${name}.dev` : name;
const isProd = mode === 'prod';
const isProd = mode === 'prod';
const entryMap = Object.fromEntries(entries.map(({ src, name }) => [isProd ? name : `${name}.dev`, src]));
configs.push({
entry: { [outName]: src },
platform: 'browser',
format: 'es',
target: 'es2022',
sourcemap: true,
clean: false,
dts: false,
minify: isProd,
noExternal: [/.*/],
inlineOnly: false,
treeshake: {
moduleSideEffects: [{ test: /\/define\//, sideEffects: true }],
},
outDir: 'cdn',
alias: {
'@': new URL('./src', import.meta.url).pathname,
},
define: {
__DEV__: isProd ? 'false' : 'true',
},
plugins: [inlineCssPlugin({ skinsDir, minify: isProd }), inlineTemplatePlugin({ minify: isProd })],
inputOptions:
mode === 'dev'
? {
resolve: {
conditionNames: ['development', 'import', 'browser', 'default'],
},
}
: undefined,
});
}
configs.push({
entry: entryMap,
platform: 'browser',
format: 'es',
target: 'es2022',
sourcemap: true,
clean: mode === 'dev',
dts: false,
minify: isProd,
noExternal: [/.*/],
inlineOnly: false,
treeshake: {
moduleSideEffects: [{ test: /\/define\//, sideEffects: true }],
},
outDir,
alias: {
'@': new URL('./src', import.meta.url).pathname,
},
define: {
__DEV__: isProd ? 'false' : 'true',
},
plugins: [
inlineCssPlugin({ skinsDir, minify: isProd }),
inlineTemplatePlugin({ minify: isProd }),
...(!isProd ? [dtsStubsPlugin(outDir)] : []),
],
inputOptions: !isProd
? {
resolve: {
conditionNames: ['development', 'import', 'browser', 'default'],
},
}
: undefined,
});
}
export default defineConfig(configs);
+1 -1
View File
@@ -1,4 +1,4 @@
export const SKINS = ['default', 'minimal'] as const;
export const PLATFORMS = ['html', 'react'] as const;
export const PLATFORMS = ['html', 'react', 'cdn'] as const;
export const STYLINGS = ['css', 'tailwind'] as const;
export const PRESETS = ['video', 'hls-video', 'simple-hls-video', 'dash-video', 'audio', 'background-video'] as const;
+6 -4
View File
@@ -14,6 +14,7 @@ import { Navbar } from './navbar';
import { Preview } from './preview';
function getPagePath(platform: Platform, preset: Preset): string {
if (platform === 'cdn') return '/cdn/';
if (preset === 'background-video') return `/${platform}-background-video/`;
return `/${platform}-${preset}/`;
}
@@ -68,12 +69,12 @@ export function App() {
}
}, [preset, source, setSource]);
// Background video does not have a Tailwind skin variant.
// CDN and background video do not have a Tailwind skin variant.
useEffect(() => {
if (preset === 'background-video' && styling === 'tailwind') {
if ((platform === 'cdn' || preset === 'background-video') && styling === 'tailwind') {
setStyling('css');
}
}, [preset, styling]);
}, [platform, preset, styling]);
const availableSources = preset === 'audio' ? MP4_SOURCE_IDS : preset === 'dash-video' ? DASH_SOURCE_IDS : SOURCE_IDS;
@@ -101,9 +102,10 @@ export function App() {
sources={SOURCES}
/>
<Preview
key={`${pagePath}:${styling}`}
key={`${pagePath}:${preset}:${styling}`}
ref={iframeRef}
pagePath={pagePath}
preset={preset}
skin={skin}
styling={styling}
source={source}
+2 -1
View File
@@ -27,6 +27,7 @@ const SKIN_OPTIONS: readonly Skin[] = ['default', 'minimal'] satisfies readonly
const PLATFORM_LABELS: Record<Platform, string> = {
html: 'HTML',
react: 'React',
cdn: 'CDN',
};
const PRESET_LABELS: Record<Preset, string> = {
@@ -80,7 +81,7 @@ export function Navbar({
options={stylings.map((s) => ({
value: s,
label: s === 'css' ? 'CSS' : 'Tailwind',
disabled: s === 'tailwind' && isBackgroundVideo,
disabled: s === 'tailwind' && (isBackgroundVideo || platform === 'cdn'),
}))}
/>
+5 -4
View File
@@ -1,24 +1,25 @@
import type { SourceId } from '@app/shared/sources';
import type { Skin, Styling } from '@app/types';
import type { Preset, Skin, Styling } from '@app/types';
import { forwardRef, useState } from 'react';
type PreviewProps = {
pagePath: string;
preset: Preset;
skin: Skin;
styling: Styling;
source: SourceId;
};
export const Preview = forwardRef<HTMLIFrameElement, PreviewProps>(function Preview(
{ pagePath, skin, styling, source },
{ pagePath, preset, skin, styling, source },
ref
) {
const [iframeUrl] = useState(
() =>
`${pagePath}?skin=${encodeURIComponent(skin)}&styling=${encodeURIComponent(styling)}&source=${encodeURIComponent(source)}`
`${pagePath}?preset=${encodeURIComponent(preset)}&skin=${encodeURIComponent(skin)}&styling=${encodeURIComponent(styling)}&source=${encodeURIComponent(source)}`
);
const openUrl =
`${pagePath}?skin=${encodeURIComponent(skin)}&styling=${encodeURIComponent(styling)}` +
`${pagePath}?preset=${encodeURIComponent(preset)}&skin=${encodeURIComponent(skin)}&styling=${encodeURIComponent(styling)}` +
`&source=${encodeURIComponent(source)}`;
return (
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sandbox — CDN</title>
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
</head>
<body class="font-sans">
<div id="root" class="flex justify-center items-center min-h-screen"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
+162
View File
@@ -0,0 +1,162 @@
import '@app/styles.css';
import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state';
import { CSS_SKIN_TAGS } from '@app/shared/html/skin-tags';
import { renderStoryboard } from '@app/shared/html/storyboard';
import { loadAudioStylesheets, loadVideoStylesheets } from '@app/shared/html/stylesheets';
import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import { BACKGROUND_VIDEO_SRC, getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources';
import type { Preset, Skin } from '@app/types';
const html = String.raw;
const params = new URLSearchParams(location.search);
const preset = (params.get('preset') ?? 'video') as Preset;
const state = createHtmlSandboxState(preset === 'audio');
const loadLatest = createLatestLoader();
// ---------------------------------------------------------------------------
// CDN module loading — mirrors the exact import graph of each CDN bundle.
// ---------------------------------------------------------------------------
async function loadCdnPreset(preset: Preset, skin: Skin) {
switch (preset) {
case 'video':
case 'hls-video':
case 'simple-hls-video':
case 'dash-video':
if (skin === 'minimal') await import('@videojs/html/cdn/video-minimal');
else await import('@videojs/html/cdn/video');
break;
case 'audio':
if (skin === 'minimal') await import('@videojs/html/cdn/audio-minimal');
else await import('@videojs/html/cdn/audio');
break;
case 'background-video':
await import('@videojs/html/background/skin.css');
await import('@videojs/html/cdn/background');
break;
}
}
async function loadCdnMedia(preset: Preset) {
switch (preset) {
case 'hls-video':
await import('@videojs/html/cdn/media/hls-video');
break;
case 'simple-hls-video':
await import('@videojs/html/cdn/media/simple-hls-video');
break;
case 'dash-video':
await import('@videojs/html/cdn/media/dash-video');
break;
}
}
// ---------------------------------------------------------------------------
// Rendering — produces the exact HTML markup the installation builder generates.
// ---------------------------------------------------------------------------
function getPlayerTag(preset: Preset): string {
if (preset === 'background-video') return 'background-video-player';
if (preset === '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;
return CSS_SKIN_TAGS[skin].video;
}
function getMediaTag(preset: Preset): string {
const tags: Partial<Record<Preset, string>> = {
'hls-video': 'hls-video',
'simple-hls-video': 'simple-hls-video',
'dash-video': 'dash-video',
audio: 'audio',
'background-video': 'background-video',
};
return tags[preset] ?? 'video';
}
function loadStylesheets(preset: Preset, skin: Skin) {
if (preset === 'audio') loadAudioStylesheets(skin);
else if (preset !== 'background-video') loadVideoStylesheets(skin);
// Background CSS is loaded via dynamic import in loadCdnPreset.
}
function isVideoPreset(preset: Preset): boolean {
return preset === 'video' || preset === 'hls-video' || preset === 'simple-hls-video' || preset === 'dash-video';
}
async function render() {
await loadLatest(async () => {
await loadCdnPreset(preset, state.skin);
await loadCdnMedia(preset);
});
loadStylesheets(preset, state.skin);
const root = document.getElementById('root')!;
const playerTag = getPlayerTag(preset);
const skinTag = getSkinTag(preset, state.skin);
const mediaTag = getMediaTag(preset);
const src = preset === 'background-video' ? BACKGROUND_VIDEO_SRC : SOURCES[state.source].url;
const storyboard = isVideoPreset(preset) ? getStoryboardSrc(state.source) : undefined;
const poster = isVideoPreset(preset) ? getPosterSrc(state.source) : undefined;
// Background video needs viewport dimensions instead of flex centering.
if (preset === 'background-video') {
root.className = '';
root.style.cssText = 'width: 100vw; height: 100vh;';
}
if (preset === 'background-video') {
root.innerHTML = html`
<${playerTag}>
<${skinTag}>
<${mediaTag} src="${src}"></${mediaTag}>
</${skinTag}>
</${playerTag}>
`;
return;
}
if (preset === 'audio') {
root.innerHTML = html`
<div class="w-full max-w-xl mx-auto">
<${playerTag}>
<${skinTag}>
<${mediaTag} src="${src}"></${mediaTag}>
</${skinTag}>
</${playerTag}>
</div>
`;
return;
}
root.innerHTML = html`
<${playerTag}>
<${skinTag} class="w-full aspect-video max-w-4xl mx-auto">
<${mediaTag} src="${src}" playsinline crossorigin="anonymous">
${renderStoryboard(storyboard)}
</${mediaTag}>
${poster ? html`<img slot="poster" src="${poster}" alt="Video poster" />` : ''}
</${skinTag}>
</${playerTag}>
`;
}
render();
onSkinChange((skin) => {
state.skin = skin;
render();
});
onSourceChange((source) => {
state.source = source;
render();
});
+1 -1
View File
@@ -5,7 +5,7 @@
"globalEnv": ["CONTEXT", "DEPLOY_PRIME_URL"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"dependsOn": ["^build", "^build:cdn"],
"outputs": ["dist/**", ".netlify/**"]
},
"build:cdn": {