diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1856bbf72..b24fbd5f26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,6 +111,7 @@ jobs: - '@videojs/element' - '@videojs/html' - '@videojs/react' + - '@videojs/sandbox' steps: - name: Checkout code diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 027a577214..b14cdfc1e4 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -113,6 +113,11 @@ jobs: # open a running player built from the pull request. Its `dev` script # regenerates the gitignored src/ from templates/ on boot, so the sandbox # still works even though src/ is never part of the upload. + # The template is apps/sandbox alone: no workspace catalog to resolve its `catalog:` specs, and no preview of + # the private packages its `workspace:` ranges name. Rewrite its manifest in place; this checkout is disposable. + - name: Prepare the StackBlitz template + run: pnpm --filter @videojs/sandbox exec tsx scripts/prepare-template.ts + - name: Publish preview packages env: VIDEOJS_SKIP_PACKAGE_DOCS: '1' diff --git a/apps/e2e/suites/player/app/vite.config.ts b/apps/e2e/suites/player/app/vite.config.ts index 6ea1a802e9..fd75c79140 100644 --- a/apps/e2e/suites/player/app/vite.config.ts +++ b/apps/e2e/suites/player/app/vite.config.ts @@ -41,7 +41,7 @@ function getPageEntries(): Record { } export default defineConfig({ - root: 'src', + root: resolve(packageDir, 'src'), appType: 'mpa', define: { __DEV__: 'true', diff --git a/apps/e2e/suites/player/playwright.config.ts b/apps/e2e/suites/player/playwright.config.ts index d503cd2c85..60ad2a969b 100644 --- a/apps/e2e/suites/player/playwright.config.ts +++ b/apps/e2e/suites/player/playwright.config.ts @@ -21,10 +21,14 @@ export default defineConfig({ use: { ...devices['Desktop Firefox'], baseURL: 'http://localhost:5180' }, }, ], + // The workspace overrides vite with the Vite+ core package, which only ships the vp binary, and the app folder is not + // a workspace package, so run Vite+ from the e2e package and point it at the app directory. webServer: { - command: 'pnpm exec vite --port 5180', - cwd: resolve(import.meta.dirname, 'app'), + command: 'pnpm exec vp -C suites/player/app dev --port 5180 --strictPort', + cwd: resolve(import.meta.dirname, '../..'), port: 5180, + stdout: 'pipe', + stderr: 'pipe', reuseExistingServer: !process.env.CI, timeout: 120_000, }, diff --git a/apps/e2e/suites/registry/overlays/vite/main.ts b/apps/e2e/suites/registry/overlays/html/main.ts similarity index 100% rename from apps/e2e/suites/registry/overlays/vite/main.ts rename to apps/e2e/suites/registry/overlays/html/main.ts diff --git a/apps/e2e/suites/registry/overlays/vite/style.css b/apps/e2e/suites/registry/overlays/html/style.css similarity index 100% rename from apps/e2e/suites/registry/overlays/vite/style.css rename to apps/e2e/suites/registry/overlays/html/style.css diff --git a/apps/e2e/suites/registry/overlays/next/player.tsx b/apps/e2e/suites/registry/overlays/react/player.tsx similarity index 100% rename from apps/e2e/suites/registry/overlays/next/player.tsx rename to apps/e2e/suites/registry/overlays/react/player.tsx diff --git a/apps/e2e/suites/registry/overlays/rspack/package.json b/apps/e2e/suites/registry/overlays/rspack/package.json new file mode 100644 index 0000000000..4c1f38502b --- /dev/null +++ b/apps/e2e/suites/registry/overlays/rspack/package.json @@ -0,0 +1,13 @@ +{ + "name": "rspack-html-css", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "rspack build --mode production" + }, + "devDependencies": { + "@rspack/cli": "2.2.2", + "@rspack/core": "2.2.2", + "typescript": "5.9.3" + } +} diff --git a/apps/e2e/suites/registry/overlays/rspack/rspack.config.mjs b/apps/e2e/suites/registry/overlays/rspack/rspack.config.mjs new file mode 100644 index 0000000000..03e54bdf0b --- /dev/null +++ b/apps/e2e/suites/registry/overlays/rspack/rspack.config.mjs @@ -0,0 +1,30 @@ +import { fileURLToPath } from 'node:url'; + +import { rspack } from '@rspack/core'; + +const source = fileURLToPath(new URL('./src', import.meta.url)); + +export default { + entry: `${source}/main.ts`, + output: { + path: fileURLToPath(new URL('./dist', import.meta.url)), + filename: '[name].[contenthash].js', + clean: true, + }, + resolve: { + extensions: ['.ts', '.js'], + alias: { '@': source }, + }, + module: { + rules: [ + { test: /\.ts$/, exclude: /node_modules/, loader: 'builtin:swc-loader', type: 'javascript/auto' }, + // The registry's HTML skin templates are imported as strings. + { resourceQuery: /raw/, type: 'asset/source' }, + { test: /\.css$/, type: 'css' }, + ], + }, + plugins: [new rspack.HtmlRspackPlugin({ template: `${source}/index.html` })], + // Rspack's built-in CSS pipeline is opt-in; the skin's stylesheets are imported from its module. + experiments: { css: true }, + performance: { hints: false }, +}; diff --git a/packages/skins/dev/index.html b/apps/e2e/suites/registry/overlays/rspack/src/index.html similarity index 54% rename from packages/skins/dev/index.html rename to apps/e2e/suites/registry/overlays/rspack/src/index.html index 2713797b49..08fa4b50db 100644 --- a/packages/skins/dev/index.html +++ b/apps/e2e/suites/registry/overlays/rspack/src/index.html @@ -3,11 +3,9 @@ - - Video.js Skins + Video.js registry consumer -
- +
diff --git a/apps/e2e/suites/registry/overlays/rspack/tsconfig.json b/apps/e2e/suites/registry/overlays/rspack/tsconfig.json new file mode 100644 index 0000000000..84e8475aa7 --- /dev/null +++ b/apps/e2e/suites/registry/overlays/rspack/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true, + "baseUrl": ".", + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src"] +} diff --git a/apps/e2e/suites/registry/overlays/static/serve.mjs b/apps/e2e/suites/registry/overlays/static/serve.mjs new file mode 100644 index 0000000000..3c71441902 --- /dev/null +++ b/apps/e2e/suites/registry/overlays/static/serve.mjs @@ -0,0 +1,38 @@ +// Serves a bundler's `dist/` the way any static host would, so the suite drives the built output rather than a dev server. +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { extname, join, normalize, resolve } from 'node:path'; + +const [directory = 'dist', port = '0'] = process.argv.slice(2); +const root = resolve(directory); +const types = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', +}; + +createServer(async (request, response) => { + const { pathname } = new URL(request.url ?? '/', 'http://127.0.0.1'); + const path = normalize(join(root, pathname.endsWith('/') ? `${pathname}index.html` : pathname)); + + if (!path.startsWith(root)) { + response.statusCode = 400; + response.end('Invalid path.'); + return; + } + + try { + const body = await readFile(path); + + response.setHeader('content-type', types[extname(path)] ?? 'application/octet-stream'); + response.end(body); + } catch { + response.statusCode = 404; + response.end('Not found.'); + } +}).listen(Number(port), '127.0.0.1'); diff --git a/apps/e2e/suites/registry/overlays/webpack/package.json b/apps/e2e/suites/registry/overlays/webpack/package.json new file mode 100644 index 0000000000..380aa3e1bf --- /dev/null +++ b/apps/e2e/suites/registry/overlays/webpack/package.json @@ -0,0 +1,23 @@ +{ + "name": "webpack-react-css", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "webpack --mode production" + }, + "dependencies": { + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5", + "css-loader": "7.1.5", + "html-webpack-plugin": "5.6.8", + "style-loader": "4.0.0", + "ts-loader": "9.6.2", + "typescript": "5.9.3", + "webpack": "5.110.3", + "webpack-cli": "7.2.3" + } +} diff --git a/apps/e2e/suites/registry/overlays/webpack/src/env.d.ts b/apps/e2e/suites/registry/overlays/webpack/src/env.d.ts new file mode 100644 index 0000000000..35306c6fc9 --- /dev/null +++ b/apps/e2e/suites/registry/overlays/webpack/src/env.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/apps/e2e/suites/registry/overlays/webpack/src/index.html b/apps/e2e/suites/registry/overlays/webpack/src/index.html new file mode 100644 index 0000000000..08fa4b50db --- /dev/null +++ b/apps/e2e/suites/registry/overlays/webpack/src/index.html @@ -0,0 +1,11 @@ + + + + + + Video.js registry consumer + + +
+ + diff --git a/apps/e2e/suites/registry/overlays/webpack/src/main.tsx b/apps/e2e/suites/registry/overlays/webpack/src/main.tsx new file mode 100644 index 0000000000..5b21967f3b --- /dev/null +++ b/apps/e2e/suites/registry/overlays/webpack/src/main.tsx @@ -0,0 +1,10 @@ +import { createRoot } from 'react-dom/client'; + +import { Player } from './player'; + +import './style.css'; + +const root = document.querySelector('#app'); +if (!root) throw new Error('Could not find the application root.'); + +createRoot(root).render(); diff --git a/apps/e2e/suites/registry/overlays/webpack/src/style.css b/apps/e2e/suites/registry/overlays/webpack/src/style.css new file mode 100644 index 0000000000..cddb5ee9f6 --- /dev/null +++ b/apps/e2e/suites/registry/overlays/webpack/src/style.css @@ -0,0 +1,5 @@ +body { + margin: 0; + padding: 2rem; + font-family: system-ui, sans-serif; +} diff --git a/apps/e2e/suites/registry/overlays/webpack/tsconfig.json b/apps/e2e/suites/registry/overlays/webpack/tsconfig.json new file mode 100644 index 0000000000..eb823c1d5b --- /dev/null +++ b/apps/e2e/suites/registry/overlays/webpack/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "baseUrl": ".", + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src"] +} diff --git a/apps/e2e/suites/registry/overlays/webpack/webpack.config.mjs b/apps/e2e/suites/registry/overlays/webpack/webpack.config.mjs new file mode 100644 index 0000000000..aac8d21558 --- /dev/null +++ b/apps/e2e/suites/registry/overlays/webpack/webpack.config.mjs @@ -0,0 +1,27 @@ +import { fileURLToPath } from 'node:url'; + +import HtmlWebpackPlugin from 'html-webpack-plugin'; + +const source = fileURLToPath(new URL('./src', import.meta.url)); + +export default { + entry: `${source}/main.tsx`, + output: { + path: fileURLToPath(new URL('./dist', import.meta.url)), + filename: '[name].[contenthash].js', + clean: true, + }, + resolve: { + extensions: ['.tsx', '.ts', '.js'], + alias: { '@': source }, + }, + module: { + rules: [ + // Types are checked separately with `tsc`; the loader only strips them. + { test: /\.tsx?$/, exclude: /node_modules/, loader: 'ts-loader', options: { transpileOnly: true } }, + { test: /\.css$/, use: ['style-loader', 'css-loader'] }, + ], + }, + plugins: [new HtmlWebpackPlugin({ template: `${source}/index.html` })], + performance: { hints: false }, +}; diff --git a/apps/e2e/suites/registry/projects.ts b/apps/e2e/suites/registry/projects.ts index 08fd8ee1c9..9934ecc75e 100644 --- a/apps/e2e/suites/registry/projects.ts +++ b/apps/e2e/suites/registry/projects.ts @@ -1,9 +1,13 @@ import { skinCatalog } from '../../../../packages/skins/build/catalog.ts'; +/** The tool that scaffolds, builds, and serves a consumer. Bundler compatibility is a property of the packages. */ +export type RegistryConsumerBundler = 'next' | 'vite' | 'webpack' | 'rspack'; + interface RegistryConsumerProjectBase { readonly name: string; readonly directory: string; readonly port: number; + readonly bundler: RegistryConsumerBundler; } export type RegistryConsumerProject = RegistryConsumerProjectBase & @@ -24,6 +28,7 @@ export const registryConsumerProjects = [ directory: 'next-react-tailwind', framework: 'react', styling: 'tailwind', + bundler: 'next', port: 5310, }, { @@ -31,6 +36,7 @@ export const registryConsumerProjects = [ directory: 'next-react-css', framework: 'react', styling: 'css', + bundler: 'next', port: 5311, }, { @@ -38,6 +44,23 @@ export const registryConsumerProjects = [ directory: 'vite-html-css', framework: 'html', styling: 'css', + bundler: 'vite', port: 5312, }, + { + name: 'webpack-react-css', + directory: 'webpack-react-css', + framework: 'react', + styling: 'css', + bundler: 'webpack', + port: 5313, + }, + { + name: 'rspack-html-css', + directory: 'rspack-html-css', + framework: 'html', + styling: 'css', + bundler: 'rspack', + port: 5314, + }, ] as const satisfies readonly RegistryConsumerProject[]; diff --git a/apps/e2e/suites/registry/setup/global.ts b/apps/e2e/suites/registry/setup/global.ts index 1633d34f71..1e2f1d4786 100644 --- a/apps/e2e/suites/registry/setup/global.ts +++ b/apps/e2e/suites/registry/setup/global.ts @@ -75,10 +75,9 @@ async function createConsumer( const started = performance.now(); const projectDir = resolve(generatedDir, project.directory); - if (project.framework === 'react') await scaffoldNext(project); - else await scaffoldVite(project); + await scaffold(project); - if (project.framework === 'html') await configureViteTypes(projectDir); + if (project.bundler === 'vite') await configureViteTypes(projectDir); await configurePackage(projectDir, overrides); await configureShadcn(project, projectDir, registryUrl); @@ -88,6 +87,19 @@ async function createConsumer( console.log(`Installed ${project.name} in ${elapsed(started)}.`); } +async function scaffold(project: RegistryConsumerProject): Promise { + switch (project.bundler) { + case 'next': + return scaffoldNext(project); + case 'vite': + return scaffoldVite(project); + // No official scaffold to run: the overlay is the whole project. + case 'webpack': + case 'rspack': + return cp(resolve(overlaysDir, project.bundler), resolve(generatedDir, project.directory), { recursive: true }); + } +} + async function scaffoldNext(project: RegistryConsumerProject): Promise { await run( 'pnpm', @@ -160,7 +172,7 @@ async function configureShadcn( registryUrl: string ): Promise { const sourceDir = resolve(projectDir, 'src'); - const css = project.framework === 'react' ? 'src/app/globals.css' : 'src/style.css'; + const css = project.bundler === 'next' ? 'src/app/globals.css' : 'src/style.css'; const path = registryPath(project); await mkdir(resolve(sourceDir, 'lib'), { recursive: true }); @@ -227,20 +239,28 @@ async function exerciseRegistryCli(project: RegistryConsumerProject, projectDir: } } +/** The player page is shared per framework; each bundler adds only what mounts and serves it. */ async function applyOverlay(project: RegistryConsumerProject, projectDir: string): Promise { + const sourceDir = resolve(projectDir, 'src'); + if (project.framework === 'react') { - const appDir = resolve(projectDir, 'src/app'); + const pageDir = project.bundler === 'next' ? resolve(sourceDir, 'app') : sourceDir; - await cp(resolve(overlaysDir, 'next/player.tsx'), resolve(appDir, 'player.tsx')); - await cp(resolve(overlaysDir, 'next/page.tsx'), resolve(appDir, 'page.tsx')); - return; - } + await cp(resolve(overlaysDir, 'react/player.tsx'), resolve(pageDir, 'player.tsx')); - const sourceDir = resolve(projectDir, 'src'); + if (project.bundler === 'next') await cp(resolve(overlaysDir, 'next/page.tsx'), resolve(pageDir, 'page.tsx')); + } else { + await cp(resolve(overlaysDir, 'html/main.ts'), resolve(sourceDir, 'main.ts')); + await cp(resolve(overlaysDir, 'html/style.css'), resolve(sourceDir, 'style.css')); - await cp(resolve(overlaysDir, 'vite/main.ts'), resolve(sourceDir, 'main.ts')); - await cp(resolve(overlaysDir, 'vite/style.css'), resolve(sourceDir, 'style.css')); - await cp(resolve(overlaysDir, 'vite/vite.config.ts'), resolve(projectDir, 'vite.config.ts')); + if (project.bundler === 'vite') { + await cp(resolve(overlaysDir, 'vite/vite.config.ts'), resolve(projectDir, 'vite.config.ts')); + } + } + + if (project.bundler === 'webpack' || project.bundler === 'rspack') { + await cp(resolve(overlaysDir, 'static/serve.mjs'), resolve(projectDir, 'serve.mjs')); + } } async function configureViteTypes(projectDir: string): Promise { @@ -277,7 +297,7 @@ async function verifyConsumer(project: RegistryConsumerProject): Promise { const started = performance.now(); const projectDir = resolve(generatedDir, project.directory); - if (project.framework === 'react') { + if (project.bundler === 'next') { await run('pnpm', ['--ignore-workspace', 'run', 'lint'], projectDir); } @@ -292,21 +312,8 @@ async function verifyConsumer(project: RegistryConsumerProject): Promise { async function startConsumer(project: RegistryConsumerProject): Promise { const projectDir = resolve(generatedDir, project.directory); - const args = - project.framework === 'react' - ? ['--ignore-workspace', 'run', 'start', '--hostname', '127.0.0.1', '--port', String(project.port)] - : [ - '--ignore-workspace', - 'exec', - 'vite', - 'preview', - '--host', - '127.0.0.1', - '--port', - String(project.port), - '--strictPort', - ]; - const child = spawn('pnpm', args, { + const [executable, args] = consumerServer(project); + const child = spawn(executable, args, { cwd: projectDir, detached: true, env: consumerEnvironment(), @@ -317,6 +324,24 @@ async function startConsumer(project: RegistryConsumerProject): Promise { await waitForUrl(`http://127.0.0.1:${project.port}`); } +/** Next and Vite serve their own builds; a plain bundle is served from `dist/` by the overlay's static server. */ +function consumerServer(project: RegistryConsumerProject): [string, string[]] { + const port = String(project.port); + + switch (project.bundler) { + case 'next': + return ['pnpm', ['--ignore-workspace', 'run', 'start', '--hostname', '127.0.0.1', '--port', port]]; + case 'vite': + return [ + 'pnpm', + ['--ignore-workspace', 'exec', 'vite', 'preview', '--host', '127.0.0.1', '--port', port, '--strictPort'], + ]; + case 'webpack': + case 'rspack': + return [process.execPath, ['serve.mjs', 'dist', port]]; + } +} + async function packRegistryPackages(): Promise>> { const workspacePackages = await readWorkspacePackages(); const roots = await registryPackageRoots(); diff --git a/apps/e2e/suites/registry/tests/consumer.spec.ts b/apps/e2e/suites/registry/tests/consumer.spec.ts index c285eff982..bce76dc9ee 100644 --- a/apps/e2e/suites/registry/tests/consumer.spec.ts +++ b/apps/e2e/suites/registry/tests/consumer.spec.ts @@ -61,7 +61,8 @@ test('installs a styled player with an attached media element', async ({ page }) expect(iconBox?.width).toBeGreaterThan(10); expect(iconBox?.height).toBeGreaterThan(10); - if (test.info().project.name.startsWith('next-')) { + // The React page carries a probe that reports whether the media element reached the player store. + if ((await consumer.locator('[data-media-probe]').count()) > 0) { await expect(consumer.locator('[data-media-probe]')).toHaveAttribute('data-attached', 'true'); } } diff --git a/apps/e2e/suites/sandbox/tests/sandbox-authored-skins.spec.ts b/apps/e2e/suites/sandbox/tests/sandbox-authored-skins.spec.ts new file mode 100644 index 0000000000..18a9efa314 --- /dev/null +++ b/apps/e2e/suites/sandbox/tests/sandbox-authored-skins.spec.ts @@ -0,0 +1,66 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { expect, test } from '@playwright/test'; + +const SANDBOX_BASE = process.env.SANDBOX_URL ?? 'http://localhost:5299'; + +// Authored skins compile from `packages/skins/src`, so these cases only mean something where that package exists. +const WORKSPACE_SKINS = existsSync(resolve(import.meta.dirname, '../../../../../packages/skins/package.json')); + +const CASES = [ + { platform: 'html', styling: 'css' }, + { platform: 'html', styling: 'tailwind' }, + { platform: 'react', styling: 'css' }, + { platform: 'react', styling: 'tailwind' }, +] as const; + +test.use({ trace: 'off' }); +test.skip(!WORKSPACE_SKINS, 'The authored skins are only compiled inside the workspace.'); + +for (const { platform, styling } of CASES) { + for (const skin of ['default', 'minimal'] as const) { + test(`${platform} ${skin} ${styling} renders the authored skin`, async ({ page }) => { + const errors: string[] = []; + + page.on('pageerror', (error) => errors.push(error.message)); + + const query = new URLSearchParams({ + skins: 'authored', + styling, + skin, + source: 'mp4-1', + autoplay: '0', + muted: '1', + loop: '0', + preload: 'metadata', + }); + + await page.goto(`${SANDBOX_BASE}/${platform}-video/?${query}`, { waitUntil: 'domcontentloaded' }); + + const root = page.getByRole('group', { name: 'Media player' }).first(); + + await expect(root).toBeVisible({ timeout: 30_000 }); + await expect(root).toHaveAttribute('data-theme', skin); + await expect(root.getByRole('button', { name: 'Play' })).toBeVisible(); + // The compiled skin carries the theme's control sizing either way; Tailwind reaches it through the recorded + // utilities, CSS through the module's own stylesheet. + await expect + .poll(() => + root.evaluate((element) => getComputedStyle(element).getPropertyValue('--media-control-size').trim()) + ) + .not.toBe(''); + expect(errors).toEqual([]); + }); + } +} + +test('the shell offers the authored source and the html Tailwind styling it enables', async ({ page }) => { + await page.goto(`${SANDBOX_BASE}/?platform=html&media=video&skins=authored&styling=tailwind&source=mp4-1`, { + waitUntil: 'domcontentloaded', + }); + + await expect(page.locator('iframe[title="player demo"]')).toHaveAttribute('src', /skins=authored/); + await expect(page.locator('iframe[title="player demo"]')).toHaveAttribute('src', /styling=tailwind/); + await expect(page).toHaveURL(/[?&]skins=authored(?:&|$)/); +}); diff --git a/apps/e2e/suites/sandbox/tests/sandbox-cdn-i18n.spec.ts b/apps/e2e/suites/sandbox/tests/sandbox-cdn-i18n.spec.ts index 2c1249250c..cce4863515 100644 --- a/apps/e2e/suites/sandbox/tests/sandbox-cdn-i18n.spec.ts +++ b/apps/e2e/suites/sandbox/tests/sandbox-cdn-i18n.spec.ts @@ -27,7 +27,7 @@ async function getPreviewFrame(page: Page, path: string): Promise { test.describe('Sandbox CDN i18n', () => { test('direct CDN page shows Spanish play label', async ({ page }) => { await page.goto( - `${SANDBOX_BASE}/cdn/?preset=video&locale=es&styling=css&skin=default&source=hls-1&autoplay=0&muted=0&loop=0&preload=metadata`, + `${SANDBOX_BASE}/cdn/?media=video&locale=es&styling=css&skin=default&source=hls-1&autoplay=0&muted=0&loop=0&preload=metadata`, { waitUntil: 'domcontentloaded' } ); const player = new PlayerPage(page); @@ -37,7 +37,7 @@ test.describe('Sandbox CDN i18n', () => { test('shell iframe shows Spanish play label', async ({ page }) => { await page.goto( - `${SANDBOX_BASE}/?platform=cdn&preset=video&locale=es&styling=css&skin=default&source=hls-1&autoplay=0&muted=0&loop=0&preload=metadata`, + `${SANDBOX_BASE}/?platform=cdn&media=video&locale=es&styling=css&skin=default&source=hls-1&autoplay=0&muted=0&loop=0&preload=metadata`, { waitUntil: 'domcontentloaded' } ); const frame = await getPreviewFrame(page, '/cdn/'); @@ -50,7 +50,7 @@ test.describe('Sandbox CDN i18n', () => { test('direct CDN page applies RTL direction to the document and player', async ({ page }) => { await page.goto( - `${SANDBOX_BASE}/cdn/?preset=video&locale=ar&styling=css&skin=default&source=hls-1&autoplay=0&muted=0&loop=0&preload=metadata`, + `${SANDBOX_BASE}/cdn/?media=video&locale=ar&styling=css&skin=default&source=hls-1&autoplay=0&muted=0&loop=0&preload=metadata`, { waitUntil: 'domcontentloaded' } ); diff --git a/apps/e2e/suites/sandbox/tests/sandbox-compare.spec.ts b/apps/e2e/suites/sandbox/tests/sandbox-compare.spec.ts new file mode 100644 index 0000000000..977838faea --- /dev/null +++ b/apps/e2e/suites/sandbox/tests/sandbox-compare.spec.ts @@ -0,0 +1,110 @@ +import { expect, type Frame, type Page, test } from '@playwright/test'; + +const SANDBOX_BASE = process.env.SANDBOX_URL ?? 'http://localhost:5299'; + +const QUERY = 'skin=default&source=mp4-1&autoplay=0&muted=1&loop=0&preload=metadata'; + +test.use({ trace: 'off' }); + +async function getPanelFrame(page: Page, id: string): Promise { + const iframe = page.locator(`iframe[data-panel="${id}"]`); + + await expect(iframe).toBeVisible(); + + const url = await iframe.getAttribute('src'); + if (!url) throw new Error(`Panel ${id} has no frame URL.`); + + await expect + .poll(() => + page + .frames() + .find((frame) => frame.url().endsWith(url)) + ?.url() + ) + .toBeDefined(); + + const frame = page.frames().find((frame) => frame.url().endsWith(url)); + if (!frame) throw new Error(`Panel ${id} frame not found.`); + + return frame; +} + +async function playerBox(frame: Frame) { + const root = frame.getByRole('group', { name: 'Media player' }).first(); + + await expect(root).toBeVisible({ timeout: 15_000 }); + + const box = await root.boundingBox(); + if (!box) throw new Error('Expected the media player to have a rendered box.'); + + return box; +} + +test.describe('Sandbox compare', () => { + test('compares the two stylings side by side with one width', async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 900 }); + await page.goto(`${SANDBOX_BASE}/?platform=react&media=video&compare=styling&layout=row&width=480&${QUERY}`, { + waitUntil: 'domcontentloaded', + }); + + const css = page.locator('iframe[data-panel="css"]'); + const tailwind = page.locator('iframe[data-panel="tailwind"]'); + + await expect(css).toHaveAttribute('src', /styling=css/); + await expect(css).toHaveAttribute('src', /skins=package/); + await expect(tailwind).toHaveAttribute('src', /styling=tailwind/); + await expect(tailwind).toHaveAttribute('src', /skins=registry/); + await expect(page.locator('[data-panel="css"] header')).toHaveText(/CSS/); + await expect(page.locator('[data-panel="tailwind"] header')).toHaveText(/Tailwind/); + + const [left, right] = await Promise.all([ + playerBox(await getPanelFrame(page, 'css')), + playerBox(await getPanelFrame(page, 'tailwind')), + ]); + + expect(Math.round(left.width)).toBe(480); + expect(Math.round(right.width)).toBe(480); + + const [cssBox, tailwindBox] = await Promise.all([css.boundingBox(), tailwind.boundingBox()]); + + expect(cssBox && tailwindBox && cssBox.x + cssBox.width <= tailwindBox.x).toBe(true); + expect(cssBox && tailwindBox && Math.abs(cssBox.y - tailwindBox.y) < 2).toBe(true); + }); + + test('stacks the html and react players when asked', async ({ page }) => { + await page.goto(`${SANDBOX_BASE}/?platform=html&media=video&compare=platform&layout=column&${QUERY}`, { + waitUntil: 'domcontentloaded', + }); + + const html = page.locator('iframe[data-panel="html"]'); + const react = page.locator('iframe[data-panel="react"]'); + + await expect(html).toHaveAttribute('src', /^\/html-video\//); + await expect(react).toHaveAttribute('src', /^\/react-video\//); + + await playerBox(await getPanelFrame(page, 'html')); + + const [htmlBox, reactBox] = await Promise.all([html.boundingBox(), react.boundingBox()]); + + expect(htmlBox && reactBox && htmlBox.y + htmlBox.height <= reactBox.y).toBe(true); + expect(htmlBox && reactBox && Math.abs(htmlBox.x - reactBox.x) < 2).toBe(true); + await expect(page.getByRole('radio', { name: 'Stacked' })).toHaveAttribute('aria-checked', 'true'); + }); + + test('states the selection and switches compare off for a media without a skin choice', async ({ page }) => { + await page.goto(`${SANDBOX_BASE}/?platform=react&media=mux-video&compare=skin&${QUERY}`, { + waitUntil: 'domcontentloaded', + }); + + await expect(page.getByTestId('selection-summary')).toHaveText( + /React · Mux Video · Default · CSS · from the package/ + ); + await expect(page.locator('iframe[data-panel]')).toHaveCount(2); + + await page.getByLabel('Media').selectOption('background-video'); + + await expect(page.locator('iframe[data-panel]')).toHaveCount(1); + await expect(page).not.toHaveURL(/[?&]compare=/); + await expect(page.getByTestId('selection-summary')).toHaveText(/React · Background Video · fixed source/); + }); +}); diff --git a/apps/e2e/suites/sandbox/tests/sandbox-html-i18n.spec.ts b/apps/e2e/suites/sandbox/tests/sandbox-html-i18n.spec.ts index 9efde813be..e3a2340dba 100644 --- a/apps/e2e/suites/sandbox/tests/sandbox-html-i18n.spec.ts +++ b/apps/e2e/suites/sandbox/tests/sandbox-html-i18n.spec.ts @@ -67,7 +67,7 @@ test.describe('Sandbox HTML i18n', () => { }); test('shell iframe shows Spanish play label', async ({ page }) => { - await page.goto(`${SANDBOX_BASE}/?platform=html&preset=video&${QUERY}`, { + await page.goto(`${SANDBOX_BASE}/?platform=html&media=video&${QUERY}`, { waitUntil: 'domcontentloaded', }); const frame = await getPreviewFrame(page, '/html-video/'); @@ -93,7 +93,7 @@ test.describe('Sandbox React i18n', () => { }); test('shell iframe shows Spanish play label', async ({ page }) => { - await page.goto(`${SANDBOX_BASE}/?platform=react&preset=video&${QUERY}`, { + await page.goto(`${SANDBOX_BASE}/?platform=react&media=video&${QUERY}`, { waitUntil: 'domcontentloaded', }); const frame = await getPreviewFrame(page, '/react-video/'); diff --git a/apps/e2e/suites/sandbox/tests/sandbox-mirror.spec.ts b/apps/e2e/suites/sandbox/tests/sandbox-mirror.spec.ts new file mode 100644 index 0000000000..3172b3704b --- /dev/null +++ b/apps/e2e/suites/sandbox/tests/sandbox-mirror.spec.ts @@ -0,0 +1,106 @@ +import { expect, type Frame, type Page, test } from '@playwright/test'; + +const SANDBOX_BASE = process.env.SANDBOX_URL ?? 'http://localhost:5299'; + +const QUERY = 'skin=default&source=mp4-1&autoplay=0&muted=1&loop=0&preload=metadata'; + +test.use({ trace: 'off' }); + +async function getPanelFrame(page: Page, id: string): Promise { + const iframe = page.locator(`iframe[data-panel="${id}"]`); + + await expect(iframe).toBeVisible(); + + const url = await iframe.getAttribute('src'); + if (!url) throw new Error(`Panel ${id} has no frame URL.`); + + await expect + .poll(() => + page + .frames() + .find((frame) => frame.url().endsWith(url)) + ?.url() + ) + .toBeDefined(); + + const frame = page.frames().find((frame) => frame.url().endsWith(url)); + if (!frame) throw new Error(`Panel ${id} frame not found.`); + + return frame; +} + +/** The native element both pages render for the MP4 source. */ +function mediaState(frame: Frame) { + return frame.evaluate(() => { + const media = document.querySelector('video'); + if (!media) throw new Error('Expected a video element.'); + + return { paused: media.paused, muted: media.muted, currentTime: media.currentTime, rate: media.playbackRate }; + }); +} + +test.describe('Sandbox mirror', () => { + test('carries playback from one panel to the other', async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 900 }); + await page.goto( + `${SANDBOX_BASE}/?platform=html&media=video&compare=platform&layout=row&mirror=1&width=480&${QUERY}`, + { + waitUntil: 'domcontentloaded', + } + ); + + const html = await getPanelFrame(page, 'html'); + const react = await getPanelFrame(page, 'react'); + + for (const frame of [html, react]) { + await expect(frame.getByRole('group', { name: 'Media player' }).first()).toBeVisible({ timeout: 15_000 }); + await expect.poll(() => frame.evaluate(() => (document.querySelector('video')?.readyState ?? 0) >= 1)).toBe(true); + } + + await expect(page.getByLabel('Mirror playback')).toBeChecked(); + + // Play in the html panel; the react panel follows. + await html.getByRole('button', { name: 'Play' }).click(); + await expect.poll(() => mediaState(html).then((state) => state.paused)).toBe(false); + await expect.poll(() => mediaState(react).then((state) => state.paused), { timeout: 10_000 }).toBe(false); + + // Pause from the react panel; the html panel follows. + await react.getByRole('button', { name: 'Pause' }).click(); + await expect.poll(() => mediaState(react).then((state) => state.paused)).toBe(true); + await expect.poll(() => mediaState(html).then((state) => state.paused), { timeout: 10_000 }).toBe(true); + + // A seek and an unmute in one panel land in the other. + await html.evaluate(() => { + const media = document.querySelector('video'); + if (!media) throw new Error('Expected a video element.'); + + media.currentTime = 4; + media.muted = false; + }); + await expect.poll(() => mediaState(react).then((state) => Math.abs(state.currentTime - 4) < 0.5)).toBe(true); + await expect.poll(() => mediaState(react).then((state) => state.muted)).toBe(false); + }); + + test('stays off unless asked, even while comparing', async ({ page }) => { + await page.goto(`${SANDBOX_BASE}/?platform=html&media=video&compare=platform&layout=row&${QUERY}`, { + waitUntil: 'domcontentloaded', + }); + + const html = await getPanelFrame(page, 'html'); + const react = await getPanelFrame(page, 'react'); + + await expect(page.getByLabel('Mirror playback')).not.toBeChecked(); + await expect(page.locator('iframe[data-panel="html"]')).not.toHaveAttribute('src', /mirror=1/); + + await expect(html.getByRole('group', { name: 'Media player' }).first()).toBeVisible({ timeout: 15_000 }); + await expect(react.getByRole('group', { name: 'Media player' }).first()).toBeVisible({ timeout: 15_000 }); + await html.evaluate(() => { + const media = document.querySelector('video'); + if (!media) throw new Error('Expected a video element.'); + + media.muted = false; + }); + await page.waitForTimeout(500); + expect((await mediaState(react)).muted).toBe(true); + }); +}); diff --git a/apps/e2e/suites/sandbox/tests/sandbox-report.spec.ts b/apps/e2e/suites/sandbox/tests/sandbox-report.spec.ts new file mode 100644 index 0000000000..d1fc7866d2 --- /dev/null +++ b/apps/e2e/suites/sandbox/tests/sandbox-report.spec.ts @@ -0,0 +1,69 @@ +import { expect, type Frame, type Page, test } from '@playwright/test'; + +const SANDBOX_BASE = process.env.SANDBOX_URL ?? 'http://localhost:5299'; + +const QUERY = 'styling=css&skin=default&source=mp4-1&autoplay=0&muted=1&loop=0&preload=metadata'; + +test.use({ trace: 'off' }); + +async function getPreviewFrame(page: Page, path: string): Promise { + await expect(page.locator('iframe[title="player demo"]')).toHaveAttribute('src', new RegExp(`^${path}`)); + await expect + .poll(() => + page + .frames() + .find((frame) => frame.url().includes(path)) + ?.url() + ) + .toContain(path); + + const frame = page.frames().find((frame) => frame.url().includes(path)); + if (!frame) throw new Error(`Preview frame not found: ${path}`); + + return frame; +} + +test.describe('Sandbox report', () => { + test('copies a report that states the selection, the environment, and relayed errors', async ({ page }) => { + await page.goto(`${SANDBOX_BASE}/?platform=react&media=video&${QUERY}`, { waitUntil: 'domcontentloaded' }); + + const frame = await getPreviewFrame(page, '/react-video/'); + + await expect(frame.getByRole('group', { name: 'Media player' }).first()).toBeVisible({ timeout: 15_000 }); + await frame.evaluate(() => { + console.error('sandbox report probe'); + }); + + const report = page.getByRole('button', { name: /^Report/ }); + + await expect(report).toContainText('1'); + await report.click(); + + const dialog = page.getByRole('dialog', { name: 'Preview report' }); + const markdown = dialog.getByLabel('Report markdown'); + + await expect(dialog).toBeVisible(); + await expect(markdown).toHaveValue(/## Video\.js sandbox preview/); + await expect(markdown).toHaveValue(/- Selection: React · Video · Default · CSS · from the package · 896px/); + await expect(markdown).toHaveValue(/- Build: \S+ @ \S+/); + await expect(markdown).toHaveValue(/- Preferences: reduced motion (?:on|off), /); + await expect(markdown).toHaveValue(/- Errors:\n {2}- \d\d:\d\d:\d\d single: sandbox report probe/); + + await dialog.getByRole('button', { name: 'Close' }).click(); + await expect(dialog).toBeHidden(); + }); + + test('shows the detected preferences in the options panel and follows emulation', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(`${SANDBOX_BASE}/?platform=html&media=video&${QUERY}`, { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: 'Options' }).click(); + + const badges = page.getByRole('list', { name: 'Detected preferences' }); + + await expect(badges.getByText('reduced motion: on')).toBeVisible(); + await expect(badges.getByText('hover: on')).toBeVisible(); + + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await expect(badges.getByText('reduced motion: off')).toBeVisible(); + }); +}); diff --git a/apps/e2e/suites/sandbox/tests/sandbox-shell-controls.spec.ts b/apps/e2e/suites/sandbox/tests/sandbox-shell-controls.spec.ts new file mode 100644 index 0000000000..f9f486eec2 --- /dev/null +++ b/apps/e2e/suites/sandbox/tests/sandbox-shell-controls.spec.ts @@ -0,0 +1,122 @@ +import { expect, type Frame, type Page, test } from '@playwright/test'; + +const SANDBOX_BASE = process.env.SANDBOX_URL ?? 'http://localhost:5299'; + +const QUERY = 'styling=css&skin=default&source=mp4-1&autoplay=0&muted=1&loop=0&preload=metadata'; + +test.use({ trace: 'off' }); + +async function getPreviewFrame(page: Page, path: string): Promise { + await expect(page.locator('iframe[title="player demo"]')).toHaveAttribute('src', new RegExp(`^${path}`)); + await expect + .poll(() => + page + .frames() + .find((frame) => frame.url().includes(path)) + ?.url() + ) + .toContain(path); + + const frame = page.frames().find((frame) => frame.url().includes(path)); + if (!frame) throw new Error(`Preview frame not found: ${path}`); + + return frame; +} + +/** The width control lives in the options panel, which opens closed. */ +async function openOptions(page: Page): Promise { + await page.getByRole('button', { name: 'Options' }).click(); + await expect(page.getByRole('complementary', { name: 'Options' })).toBeVisible(); +} + +async function playerWidth(scope: Page | Frame): Promise { + const root = scope.getByRole('group', { name: 'Media player' }).first(); + + await expect(root).toBeVisible({ timeout: 15_000 }); + + const box = await root.boundingBox(); + if (!box) throw new Error('Expected the media player to have a rendered box.'); + + return Math.round(box.width); +} + +test.describe('Sandbox shell controls', () => { + test('the width control sizes the player in the preview', async ({ page }) => { + await page.goto(`${SANDBOX_BASE}/?platform=html&media=video&width=480&${QUERY}`, { + waitUntil: 'domcontentloaded', + }); + + const frame = await getPreviewFrame(page, '/html-video/'); + + await openOptions(page); + + const slider = page.getByRole('slider', { name: 'Width' }); + + await expect(slider).toHaveValue('480'); + await expect.poll(() => playerWidth(frame)).toBe(480); + + await slider.fill('640'); + + await expect(page).toHaveURL(/[?&]width=640(?:&|$)/); + await expect.poll(() => playerWidth(frame)).toBe(640); + }); + + test('a preview opens at its skin width until the control is touched', async ({ page }) => { + await page.goto(`${SANDBOX_BASE}/?platform=react&media=audio&${QUERY}`, { waitUntil: 'domcontentloaded' }); + + const frame = await getPreviewFrame(page, '/react-audio/'); + + await openOptions(page); + await expect(page.getByRole('slider', { name: 'Width' })).toHaveValue('576'); + await expect.poll(() => playerWidth(frame)).toBe(576); + await expect(page).not.toHaveURL(/[?&]width=/); + }); + + test('a direct page takes width, scheme, and direction from its query', async ({ page }) => { + await page.goto(`${SANDBOX_BASE}/react-video/?width=400&scheme=dark&dir=rtl&${QUERY}`, { + waitUntil: 'domcontentloaded', + }); + + const html = page.locator('html'); + const root = page.getByRole('group', { name: 'Media player' }).first(); + + await expect.poll(() => playerWidth(page)).toBe(400); + await expect(html).toHaveAttribute('data-color-scheme', 'dark'); + await expect(html).toHaveCSS('color-scheme', 'dark'); + await expect(html).toHaveAttribute('dir', 'rtl'); + await expect(root).toHaveCSS('direction', 'rtl'); + }); + + test('a pinned direction outlives the locale', async ({ page }) => { + await page.goto(`${SANDBOX_BASE}/html-video/?locale=ar&dir=ltr&${QUERY}`, { waitUntil: 'domcontentloaded' }); + + const html = page.locator('html'); + + await expect(html).toHaveAttribute('lang', 'ar'); + await expect(html).toHaveAttribute('dir', 'ltr'); + }); + + test('the scheme and direction settings reach the shell and the preview', async ({ page }) => { + await page.goto(`${SANDBOX_BASE}/?platform=html&media=video&${QUERY}`, { waitUntil: 'domcontentloaded' }); + + const frame = await getPreviewFrame(page, '/html-video/'); + const root = frame.getByRole('group', { name: 'Media player' }).first(); + + await expect(root).toBeVisible({ timeout: 15_000 }); + await expect(root).toHaveCSS('direction', 'ltr'); + + await openOptions(page); + await page.getByLabel('Direction').selectOption('rtl'); + + await expect(frame.locator('html')).toHaveAttribute('dir', 'rtl'); + await expect(root).toHaveCSS('direction', 'rtl'); + await expect(page).toHaveURL(/[?&]dir=rtl(?:&|$)/); + + await page.getByLabel('Color scheme').selectOption('light'); + + await expect(page.locator('html')).toHaveAttribute('data-color-scheme', 'light'); + await expect(frame.locator('html')).toHaveAttribute('data-color-scheme', 'light'); + await expect(frame.locator('html')).toHaveCSS('color-scheme', 'light'); + await expect(page).toHaveURL(/[?&]scheme=light(?:&|$)/); + }); +}); diff --git a/apps/e2e/suites/sandbox/tests/sandbox-skin-styling.spec.ts b/apps/e2e/suites/sandbox/tests/sandbox-skin-styling.spec.ts index ed6d6d0cb1..7a12225213 100644 --- a/apps/e2e/suites/sandbox/tests/sandbox-skin-styling.spec.ts +++ b/apps/e2e/suites/sandbox/tests/sandbox-skin-styling.spec.ts @@ -4,17 +4,21 @@ import { DATA_ATTRS, SELECTORS } from '../../../shared/fixtures/selectors'; const SANDBOX_BASE = process.env.SANDBOX_URL ?? 'http://localhost:5299'; +// Every skin source the sandbox can load without the workspace: the framework packages (CSS), the registry's html +// install (CSS), and the registry's two React catalogs. const CASES = [ - { platform: 'html', skin: 'default', styling: 'css' }, - { platform: 'html', skin: 'minimal', styling: 'css' }, - { platform: 'html', skin: 'default', styling: 'tailwind' }, - { platform: 'html', skin: 'minimal', styling: 'tailwind' }, - { platform: 'react', skin: 'default', styling: 'css' }, - { platform: 'react', skin: 'minimal', styling: 'css' }, - { platform: 'react', skin: 'default', styling: 'tailwind' }, - { platform: 'react', skin: 'minimal', styling: 'tailwind' }, + { platform: 'html', skin: 'default', styling: 'css', skins: 'package' }, + { platform: 'html', skin: 'minimal', styling: 'css', skins: 'package' }, + { platform: 'html', skin: 'default', styling: 'css', skins: 'registry' }, + { platform: 'html', skin: 'minimal', styling: 'css', skins: 'registry' }, + { platform: 'react', skin: 'default', styling: 'css', skins: 'package' }, + { platform: 'react', skin: 'minimal', styling: 'css', skins: 'package' }, + { platform: 'react', skin: 'default', styling: 'css', skins: 'registry' }, + { platform: 'react', skin: 'minimal', styling: 'css', skins: 'registry' }, + { platform: 'react', skin: 'default', styling: 'tailwind', skins: 'registry' }, + { platform: 'react', skin: 'minimal', styling: 'tailwind', skins: 'registry' }, ] as const; -const HTML_TAILWIND_ERROR_CASES = [ +const HTML_REGISTRY_ERROR_CASES = [ { media: 'video', skin: 'default' }, { media: 'video', skin: 'minimal' }, { media: 'audio', skin: 'default' }, @@ -24,10 +28,11 @@ const HTML_TAILWIND_ERROR_CASES = [ test.use({ trace: 'off' }); test.describe.configure({ mode: 'serial' }); -for (const { platform, skin, styling } of CASES) { - test(`${platform} ${skin} ${styling} uses public skin properties`, async ({ page }) => { +for (const { platform, skin, styling, skins } of CASES) { + test(`${platform} ${skin} ${styling} from ${skins} uses public skin properties`, async ({ page }) => { const query = new URLSearchParams({ styling, + skins, skin, source: 'mp4-1', autoplay: '0', @@ -42,8 +47,9 @@ for (const { platform, skin, styling } of CASES) { await expect(root).toBeVisible({ timeout: 15_000 }); + // The package's element hosts the skin's custom properties; every other install puts them on the container. const host = - platform === 'html' && styling === 'css' ? page.locator('video-skin, video-minimal-skin').first() : root; + platform === 'html' && skins === 'package' ? page.locator('video-skin, video-minimal-skin').first() : root; await host.evaluate((element) => { element.style.setProperty('--media-accent-color', '#123456'); @@ -84,11 +90,12 @@ for (const { platform, skin, styling } of CASES) { }); }); - test(`${platform} ${skin} ${styling} scales thumbnails in fullscreen`, async ({ page }) => { + test(`${platform} ${skin} ${styling} from ${skins} scales thumbnails in fullscreen`, async ({ page }) => { await page.setViewportSize({ width: 1920, height: 1080 }); const query = new URLSearchParams({ styling, + skins, skin, source: 'hls-1', autoplay: '0', @@ -160,10 +167,11 @@ for (const { platform, skin, styling } of CASES) { } for (const media of ['video', 'audio'] as const) { - for (const { platform, skin, styling } of CASES) { - test(`${platform} ${skin} ${styling} selects the live ${media} skin`, async ({ page }) => { + for (const { platform, skin, styling, skins } of CASES) { + test(`${platform} ${skin} ${styling} from ${skins} selects the live ${media} skin`, async ({ page }) => { const query = new URLSearchParams({ styling, + skins, skin, source: 'hls-live', autoplay: '0', @@ -179,6 +187,8 @@ for (const media of ['video', 'audio'] as const) { await expect(root).toBeVisible({ timeout: 15_000 }); await expect(root).toHaveAttribute('data-preset', `live-${media}`); await expect(page.getByRole('slider', { name: 'Seek' })).toHaveCount(0); + // Labels arrive through the live player's store; a skin mounted without its player element renders none. + await expect(root.getByRole('button', { name: 'Play', exact: true })).toBeVisible(); }); } } @@ -187,7 +197,7 @@ for (const media of ['video', 'audio'] as const) { for (const skin of ['default', 'minimal'] as const) { test(`cdn ${skin} selects the live ${media} skin`, async ({ page }) => { const query = new URLSearchParams({ - preset: `hls-${media}`, + media: `hls-${media}`, skin, source: 'hls-live', autoplay: '0', @@ -208,12 +218,13 @@ for (const media of ['video', 'audio'] as const) { } } -for (const { media, skin } of HTML_TAILWIND_ERROR_CASES) { - test(`html ${skin} tailwind ${media} contains the error dialog without changing the closed layout`, async ({ +for (const { media, skin } of HTML_REGISTRY_ERROR_CASES) { + test(`html ${skin} from registry ${media} contains the error dialog without changing the closed layout`, async ({ page, }) => { const query = new URLSearchParams({ - styling: 'tailwind', + styling: 'css', + skins: 'registry', skin, source: 'mp4-1', autoplay: '0', @@ -266,10 +277,11 @@ for (const { media, skin } of HTML_TAILWIND_ERROR_CASES) { }); } -for (const { platform, skin, styling } of CASES) { - test(`${platform} ${skin} ${styling} opens the volume popover`, async ({ page }) => { +for (const { platform, skin, styling, skins } of CASES) { + test(`${platform} ${skin} ${styling} from ${skins} opens the volume popover`, async ({ page }) => { const query = new URLSearchParams({ styling, + skins, skin, source: 'mp4-1', autoplay: '0', @@ -309,10 +321,11 @@ for (const { platform, skin, styling } of CASES) { }); } -for (const styling of ['css', 'tailwind'] as const) { - test(`html minimal ${styling} keeps the thumbnail inside the player`, async ({ page }) => { +for (const skins of ['package', 'registry'] as const) { + test(`html minimal from ${skins} keeps the thumbnail inside the player`, async ({ page }) => { const query = new URLSearchParams({ - styling, + styling: 'css', + skins, skin: 'minimal', source: 'hls-1', autoplay: '0', diff --git a/apps/e2e/suites/skin-parity/playwright.config.ts b/apps/e2e/suites/skin-parity/playwright.config.ts index 9a67af4507..1718b4dc12 100644 --- a/apps/e2e/suites/skin-parity/playwright.config.ts +++ b/apps/e2e/suites/skin-parity/playwright.config.ts @@ -17,15 +17,16 @@ export default defineConfig({ projects: [ { name: 'vjsc-chromium', - // Both stacked players must fit without scrolling: a capture that scrolls moves the pointer off hovered controls. + // Both stacked panels must fit without scrolling: a capture that scrolls moves the pointer off hovered controls. use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:5190', viewport: { width: 1280, height: 1600 } }, }, ], + // The sandbox hosts the comparison: its compare mode renders the two variants in two frames of the same template. webServer: { - command: 'pnpm exec vp -C dev dev --host --port 5190 --strictPort', - cwd: resolve(import.meta.dirname, '../../../../packages/skins'), + command: 'pnpm dev:sandbox --port 5190 --strictPort', + cwd: resolve(import.meta.dirname, '../../../..'), port: 5190, reuseExistingServer: !process.env.CI, - timeout: 120_000, + timeout: 300_000, }, }); diff --git a/apps/e2e/suites/skin-parity/setup/global.ts b/apps/e2e/suites/skin-parity/setup/global.ts index 913228e565..618d91de6d 100644 --- a/apps/e2e/suites/skin-parity/setup/global.ts +++ b/apps/e2e/suites/skin-parity/setup/global.ts @@ -1,42 +1,50 @@ +import { resolve } from 'node:path'; + import type { FullConfig } from '@playwright/test'; -/** Dynamic skin imports as Vite rewrites them inside the served playground loader. */ +/** Dynamic skin imports as Vite rewrites them inside the served authored-skin loaders. */ const SKIN_MODULE = /import\("([^"]+\/skin\.tsx\?[^"]+)"\)/g; -/** Dynamic imports in the served playground entry: the framework players and the Tailwind stylesheet. */ -const ENTRY_MODULE = /import\("([^"]+)"\)/g; const CONCURRENCY = 4; +const workspaceDir = resolve(import.meta.dirname, '../../../../..'); +const sandboxAppDir = resolve(workspaceDir, 'apps/sandbox/app'); +/** The template pages the parity cases open, one per player. */ +const TEMPLATE_ENTRIES = [ + '/html-video/main.ts', + '/react-video/main.tsx', + '/html-mux-video/main.ts', + '/react-mux-video/main.tsx', + '/html-audio/main.ts', + '/react-audio/main.tsx', + '/html-mux-audio/main.ts', + '/react-mux-audio/main.tsx', +]; /** - * Compile every authored skin, framework player, and the Tailwind entry before the first case runs. Cold VJSC and - * Tailwind transforms otherwise land inside test timeouts, and parallel workers would race the same compilations. The - * URLs come from the served modules, so the warm-up follows whatever Vite rewrites the imports to. + * Compile every authored skin, the template entries, and the authored Tailwind entry before the first case runs. Cold + * VJSC and Tailwind transforms otherwise land inside test timeouts, and parallel workers would race the same + * compilations. The skin URLs come from the served loader module, so the warm-up follows whatever Vite rewrites the + * imports to. */ export default async function setup(config: FullConfig): Promise { const baseURL = config.projects.find((project) => project.use.baseURL)?.use.baseURL; - if (!baseURL) throw new Error('Skin parity needs a project `baseURL` to warm the playground.'); + if (!baseURL) throw new Error('Skin parity needs a project `baseURL` to warm the sandbox.'); const started = performance.now(); - const [loaders, entry] = await Promise.all([fetchText(baseURL, '/loaders.ts'), fetchText(baseURL, '/main.tsx')]); + const loaders = await fetchText(baseURL, `/@fs${resolve(sandboxAppDir, 'shared/authored-skins.ts')}`); const skins = [...loaders.matchAll(SKIN_MODULE)].map((match) => match[1]!); - const entries = [...entry.matchAll(ENTRY_MODULE)].map((match) => match[1]!); - // Vite appends cache-busting queries after hot updates, so classify each URL by its path. - const isStylesheet = (url: string) => new URL(url, baseURL).pathname.endsWith('.css'); - const stylesheets = entries.filter(isStylesheet); - const players = entries.filter((url) => !isStylesheet(url)); - - if (skins.length === 0 || stylesheets.length === 0) throw new Error('Could not find the playground modules to warm.'); + if (skins.length === 0) throw new Error('Could not find the authored skin modules to warm.'); - await inBatches([...skins, ...players], async (url) => { + await inBatches([...skins, ...TEMPLATE_ENTRIES], async (url) => { await fetchText(baseURL, url); }); // Tailwind compiles against the candidates every skin module recorded above, so it goes last. - await inBatches(stylesheets, async (url) => { - await fetchText(baseURL, url); - }); + await fetchText(baseURL, `/@fs${resolve(sandboxAppDir, 'styles.authored.css')}`); const seconds = ((performance.now() - started) / 1000).toFixed(1); - console.log(`Warmed ${skins.length} skin modules, ${players.length} players, and Tailwind in ${seconds}s.`); + console.log( + `Warmed ${skins.length} skin modules, ${TEMPLATE_ENTRIES.length} templates, and Tailwind in ${seconds}s.` + ); } async function fetchText(baseURL: string, path: string): Promise { diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts b/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts index b7a2f3bfed..cfee0543ff 100644 --- a/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts +++ b/apps/e2e/suites/skin-parity/tests/vjsc-audio-skin-styling.spec.ts @@ -7,6 +7,7 @@ import { emulatePreference, expectRenderingParity, expectSameRendering, + frameRect, freezeSliderState, normalizeErrorDialogCopy, openComparison, @@ -199,23 +200,12 @@ async function preparePanel({ root, section }: SkinPanel, width: number, expectP } async function layoutContract(root: Locator) { - const rootRect = await root.boundingBox(); + // Frame coordinates throughout, since some parts are measured inside an evaluate. + const rootRect = await frameRect(root); const slider = root.getByRole('slider', { name: 'Seek' }).locator('..'); - if (!rootRect) throw new Error('Expected an audio skin root.'); - const roundValue = (value: number) => Math.round(value * 2) / 2; - const relativeRect = async (target: Locator) => { - const rect = await target.boundingBox(); - if (!rect) throw new Error('Expected a visible audio skin part.'); - - return { - height: roundValue(rect.height), - left: roundValue(rect.x - rootRect.x), - top: roundValue(rect.y - rootRect.y), - width: roundValue(rect.width), - }; - }; + const relativeRect = async (target: Locator) => relativeBox(await frameRect(target)); const relativeBox = (rect: { height: number; width: number; x: number; y: number }) => ({ height: roundValue(rect.height), left: roundValue(rect.x - rootRect.x), @@ -408,8 +398,8 @@ async function audioSeekContract(root: Locator) { }); const preview = slider.locator(':scope > :last-child > :last-child'); - const previewBox = await preview.boundingBox(); - if (!previewBox) throw new Error('Expected the audio seek preview to have a rendered box.'); + // The pointer position above came from the frame, so the preview's box has to as well. + const previewBox = await frameRect(preview); const offset = Math.round((previewBox.x + previewBox.width / 2 - pointer) * 10) / 10; @@ -445,9 +435,11 @@ async function audioSeekContract(root: Locator) { await page.mouse.move(pointerX, pointerY); await expect(slider).toHaveAttribute('data-dragging', ''); - const dragging = await thumb.evaluate((element, expectedX) => { + // The pointer's offset within the slider: the mouse moved in page coordinates, the thumb reports frame ones. + const dragging = await thumb.evaluate((element, expectedOffset) => { const style = getComputedStyle(element); const root = element.parentElement; + const expectedX = (root?.getBoundingClientRect().x ?? 0) + expectedOffset; const fills = [...(root?.querySelectorAll('*') ?? [])] .map((target) => getComputedStyle(target)) .filter((candidate) => @@ -468,7 +460,7 @@ async function audioSeekContract(root: Locator) { lag: lag <= 1 ? 0 : Math.ceil(lag), thumbPositionIsImmediate: !positionProperties.has('left') && !positionProperties.has('top'), }; - }, pointerX); + }, box.width * 0.73); await page.mouse.up(); return { dragging, previewOffsets, restingFillTransitions }; diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts b/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts index af44a887b9..f82e4886fc 100644 --- a/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts +++ b/apps/e2e/suites/skin-parity/tests/vjsc-live-video-skin-styling.spec.ts @@ -159,14 +159,9 @@ for (const variant of CASES) { for (const panel of comparison.panels) { contracts.push({ - captions: await feedbackContract( - page, - panel.root, - 'c', - '[data-status="captions-on"], [data-status="captions-off"]' - ), - playback: await feedbackContract(page, panel.root, 'k', '[data-status="play"], [data-status="pause"]'), - volume: await feedbackContract(page, panel.root, 'ArrowUp', '[data-level]:not([role])'), + captions: await feedbackContract(panel, 'c', '[data-status="captions-on"], [data-status="captions-off"]'), + playback: await feedbackContract(panel, 'k', '[data-status="play"], [data-status="pause"]'), + volume: await feedbackContract(panel, 'ArrowUp', '[data-level]:not([role])'), }); } diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-skin-parity.ts b/apps/e2e/suites/skin-parity/tests/vjsc-skin-parity.ts index 3cad36cb27..f8012e14a4 100644 --- a/apps/e2e/suites/skin-parity/tests/vjsc-skin-parity.ts +++ b/apps/e2e/suites/skin-parity/tests/vjsc-skin-parity.ts @@ -1,8 +1,9 @@ -import { expect, type Locator, type Page, type TestInfo } from '@playwright/test'; +import { expect, type Frame, type Locator, type Page, type TestInfo } from '@playwright/test'; -import { skinCatalog } from '../../../../../packages/skins/build/catalog.ts'; +import { skinCatalog, skinCatalogEntry } from '../../../../../packages/skins/build/catalog.ts'; import type { SkinPreset } from '../../../../../packages/skins/build/skin.ts'; import type { SkinName } from '../../../../../packages/skins/src/meta.ts'; +import { SOURCES } from '../../../../sandbox/app/shared/sources.ts'; import { expectVisualParity, type VisualCapture } from '../../../shared/fixtures/visual-parity'; export type SkinStyle = 'css' | 'tailwind'; @@ -30,7 +31,9 @@ export interface SkinPanel { readonly style: SkinStyle; /** Whether the panel renders the authored source transform or the skin the framework package ships. */ readonly source: SkinSource; - /** The compare section wrapping one variant. HTML players keep their media outside the accessible group. */ + /** The sandbox frame rendering this variant; page-wide queries have to go through it. */ + readonly frame: Frame; + /** The frame's document body. HTML players keep their media outside the accessible group. */ readonly section: Locator; /** The accessible `Media player` group rendered by one variant. */ readonly root: Locator; @@ -52,7 +55,7 @@ export interface SourceComparison { type PanelPrepare = (panel: SkinPanel) => Promise; -/** Open the playground once with both stylings of one skin rendered together, then ready each panel in order. */ +/** Open the sandbox once with both stylings of one authored skin side by side, then ready each panel in order. */ export async function openComparison( page: Page, params: Readonly>, @@ -60,8 +63,8 @@ export async function openComparison( ): Promise { const panels = await openPanels( page, - { ...params, compare: 'styles' }, - SKIN_STYLES.map((style) => ({ style, source: 'authored' as const, selector: `[data-style="${style}"]` })), + sandboxQuery(params, 'styling'), + SKIN_STYLES.map((style) => ({ style, source: 'authored' as const, panel: style })), prepare ); const [css, tailwind] = panels; @@ -70,7 +73,7 @@ export async function openComparison( return { css, tailwind, panels }; } -/** Open the playground once with the authored CSS skin beside the skin its framework package ships. */ +/** Open the sandbox once with the authored CSS skin beside the skin its framework package ships. */ export async function openSourceComparison( page: Page, params: Readonly>, @@ -79,8 +82,9 @@ export async function openSourceComparison( const sources: readonly SkinSource[] = ['authored', 'generated']; const panels = await openPanels( page, - { ...params, compare: 'source' }, - sources.map((source) => ({ style: 'css' as const, source, selector: `[data-source="${source}"]` })), + sandboxQuery(params, 'skins'), + // The sandbox names the packaged panel after its skin source. + sources.map((source) => ({ style: 'css' as const, source, panel: source === 'generated' ? 'package' : source })), prepare ); const [authored, generated] = panels; @@ -89,45 +93,129 @@ export async function openSourceComparison( return { authored, generated, panels }; } +/** + * The sandbox query for one parity case. The playground's `framework`, `skin`, `media`, `width`, and `captions` become + * the sandbox's platform, skin theme, media template, source, width, and captions; the skin source is the authored one + * and the color scheme is dark, as the playground was. An HLS source needs an engine, so it opens the Mux template; the + * MP4 and the missing file play in the native element. + */ +function sandboxQuery( + params: Readonly>, + compare: 'styling' | 'skins' +): URLSearchParams { + const { framework, skin, media = 'mp4-1', width, captions } = params; + const entry = skinCatalogEntry(String(skin) as SkinName); + const sourceId = String(media); + const source = SOURCES[sourceId as keyof typeof SOURCES]; + if (!source) throw new Error(`Unknown sandbox source: ${sourceId}.`); + + return new URLSearchParams({ + platform: String(framework), + media: source.type === 'hls' ? `mux-${entry.media}` : entry.media, + skin: entry.theme, + skins: 'authored', + styling: 'css', + compare, + layout: 'column', + width: String(width), + source: sourceId, + captions: String(captions ?? (entry.media === 'video' ? 'single' : 'none')), + scheme: 'dark', + autoplay: '0', + muted: '0', + loop: '0', + preload: 'metadata', + }); +} + +/** The frame the sandbox renders for one compare panel. */ +async function panelFrame(page: Page, id: string): Promise { + const iframe = page.locator(`iframe[data-panel="${id}"]`); + + await expect(iframe).toBeAttached(); + + const src = await iframe.getAttribute('src'); + if (!src) throw new Error(`Panel ${id} has no frame URL.`); + + await expect + .poll(() => + page + .frames() + .find((frame) => frame.url().endsWith(src)) + ?.url() + ) + .toBeDefined(); + + const frame = page.frames().find((frame) => frame.url().endsWith(src)); + if (!frame) throw new Error(`Panel ${id} frame not found.`); + + return frame; +} + async function openPanels( page: Page, - params: Readonly>, - sections: readonly { style: SkinStyle; source: SkinSource; selector: string }[], + query: URLSearchParams, + sections: readonly { style: SkinStyle; source: SkinSource; panel: string }[], prepare: PanelPrepare ): Promise { - const query = new URLSearchParams(); + await page.goto(`/?${query}`, { waitUntil: 'domcontentloaded' }); - for (const [key, value] of Object.entries(params)) query.set(key, String(value)); + const panels: SkinPanel[] = []; - await page.goto(`/?${query}`, { waitUntil: 'domcontentloaded' }); + for (const { style, source, panel } of sections) { + const frame = await panelFrame(page, panel); + const root = frame.getByRole('group', { name: 'Media player' }); - const panels = sections.map(({ style, source, selector }): SkinPanel => { - const section = page.locator(`.preview-compare-item${selector}`); + // Each frame tracks its own input modality. The playground's one document had been clicked before any panel was + // focused programmatically, so `:focus-visible` stayed off; give every frame that same first pointer contact, + // just inside the frame and above its player. + await expect(root).toBeVisible(); - return { style, source, section, root: section.getByRole('group', { name: 'Media player' }) }; - }); + const box = await root.boundingBox(); + + if (box) await page.mouse.click(box.x + box.width / 2, box.y - 8); + + panels.push({ style, source, frame, section: frame.locator('body'), root }); + } for (const panel of panels) await prepare(panel); return panels; } +/** + * One element's box in its frame's coordinate space. `boundingBox()` answers in page coordinates, which is right for + * the mouse but not for comparing against `getBoundingClientRect()` inside the frame once panels sit below one + * another. + */ +export async function frameRect(target: Locator): Promise<{ x: number; y: number; width: number; height: number }> { + return target.evaluate((element) => { + const rect = element.getBoundingClientRect(); + + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }); +} + /** * Move one element onto whole device pixels right before it is captured. Layout above a panel can settle on fractions, - * and two panels resting on different fractions rasterize every edge differently. The offset is a layout margin rather - * than a transform because the compositor snaps transformed layers on its own and reintroduces the drift. + * and two panels resting on different fractions rasterize every edge differently. The offset is a relative position + * rather than a transform, because the compositor snaps transformed layers on its own and reintroduces the drift, and + * rather than a margin, because a centred player's auto margins read back as zero and an explicit one would move it out + * from under the pointer. Calling it again is a no-op. */ export async function alignToPixelGrid(target: Locator) { await target.evaluate((element) => { if (!(element instanceof HTMLElement)) return; - element.style.marginTop = ''; - element.style.marginLeft = ''; - + const style = getComputedStyle(element); const { left, top } = element.getBoundingClientRect(); + const currentLeft = Number.parseFloat(style.left) || 0; + const currentTop = Number.parseFloat(style.top) || 0; - element.style.marginTop = `${Math.round(top) - top}px`; - element.style.marginLeft = `${Math.round(left) - left}px`; + if (style.position === 'static') element.style.position = 'relative'; + + element.style.top = `${currentTop + Math.round(top) - top}px`; + element.style.left = `${currentLeft + Math.round(left) - left}px`; }); } @@ -136,6 +224,18 @@ export interface RenderingOptions { readonly mask?: readonly Locator[] | undefined; } +/** + * Wait for the frame's web fonts before a capture. Inter loads one static weight at a time, on first use, so text that + * just appeared—an error description, a menu label—can still paint in its fallback face while the sibling panel, + * captured a moment later, already has the real one. + */ +export async function settleFonts(target: Locator) { + await target.evaluate((element) => { + element.getBoundingClientRect(); + return document.fonts.ready.then(() => undefined); + }); +} + /** Capture one rendering as the in-memory reference for a sibling panel on the same page. */ export async function captureRendering( target: Locator, @@ -143,6 +243,7 @@ export async function captureRendering( { mask = [] }: RenderingOptions = {} ): Promise { await alignToPixelGrid(target); + await settleFonts(target); return { name, image: await target.screenshot({ ...CAPTURE_OPTIONS, mask: [...mask] }) }; } @@ -154,6 +255,7 @@ export async function snapshotReference( options: RenderingOptions = {} ): Promise { await alignToPixelGrid(target); + await settleFonts(target); await expect(target).toHaveScreenshot(name, { mask: [...(options.mask ?? [])] }); return captureRendering(target, name, options); @@ -167,6 +269,7 @@ export async function expectSameRendering( { mask = [] }: RenderingOptions = {} ) { await alignToPixelGrid(target); + await settleFonts(target); const actual = { name: reference.name, image: await target.screenshot({ ...CAPTURE_OPTIONS, mask: [...mask] }) }; @@ -231,14 +334,45 @@ export async function releaseSliderState( /** Dismiss any open menu so the sibling player is not left behind an open popup before the next interaction. */ export async function closeMenus(page: Page) { - const menus = page.locator('[role="menu"]:visible'); + const frames = page.frames().filter((frame) => frame !== page.mainFrame()); + const openMenus = async () => { + const counts = await Promise.all(frames.map((frame) => frame.locator('[role="menu"]:visible').count())); + + return counts.reduce((total, count) => total + count, 0); + }; + + for (let attempt = 0; attempt < 6 && (await openMenus()) > 0; attempt++) { + // Escape steps back one submenu at a time and needs focus inside the menu, which the keyboard has where the menu + // was opened. A menu in a frame the keyboard left, or behind another frame's fullscreen player, only hears its own + // document, so the fallback is a pointer sequence dispatched inside each frame rather than a click that has to land. + if (attempt < 3) { + await page.keyboard.press('Escape'); + continue; + } - for (let attempt = 0; attempt < 6 && (await menus.count()) > 0; attempt++) { - // Escape steps back one submenu at a time and needs focus inside the menu; a click outside dismisses the rest. - await (attempt < 3 ? page.keyboard.press('Escape') : page.mouse.click(1, 1)); + for (const frame of frames) { + await frame.evaluate(() => { + if (!document.querySelector('[role="menu"]')) return; + + for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'] as const) { + const Event = type.startsWith('pointer') ? PointerEvent : MouseEvent; + + document.body.dispatchEvent( + new Event(type, { + bubbles: true, + cancelable: true, + clientX: 1, + clientY: 1, + button: 0, + pointerType: 'mouse', + }) + ); + } + }); + } } - await expect(menus).toHaveCount(0); + await expect.poll(openMenus).toBe(0); } /** Reads focus, pressed, and disabled paint for one shared button host. */ @@ -320,12 +454,14 @@ export async function controlsVisibilityContract(controls: Locator) { } /** Triggers keyboard feedback and verifies its rendered-presence lifecycle. */ -export async function feedbackContract(page: Page, root: Locator, key: string, selector: string) { +export async function feedbackContract({ frame, root }: SkinPanel, key: string, selector: string) { + const page = root.page(); + await root.focus(); await page.keyboard.press(key); await page.clock.runFor(150); - const indicator = page.locator(selector).filter({ visible: true }).first(); + const indicator = frame.locator(selector).filter({ visible: true }).first(); await indicator.waitFor({ state: 'visible' }); diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts index 3335ac2eb7..73d66430c9 100644 --- a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts +++ b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts @@ -6,6 +6,7 @@ import { emulatePreference, expectRenderingParity, expectSameRendering, + frameRect, freezeSliderState, openComparison, openSourceComparison, @@ -24,9 +25,9 @@ const BUFFERING_INDICATOR_SELECTOR = '.media-buffering-indicator, media-buffering-indicator, [class~="peer/buffering"], [class~="hidden"][class~="place-content-center"]'; const CONTROLS_SELECTOR = '.video-controls'; -test('the dev width control resizes VJSC skins', async ({ page }) => { +test('the sandbox width control resizes VJSC skins', async ({ page }) => { const { css } = await openVariants(page, REACT_DEFAULT, 384); - const range = page.getByRole('slider', { name: 'Player width' }); + const range = page.getByRole('slider', { name: 'Width' }); await range.fill('512'); @@ -161,7 +162,7 @@ for (const variant of CASES) { test(`${variant.framework} ${variant.skin} keeps popup styling in sync`, async ({ page }, testInfo) => { const name = `${variant.framework}-${variant.skin}-volume-popover.png`; const { css, tailwind } = await openVariants(page, variant, 800); - const cssPopup = await openVolumePopover(css.root); + const cssPopup = await openVolumePopover(css); const cssContract = await popupSurfaceContract(css.root, cssPopup); const cssSliderContract = await volumeSliderContract(cssPopup); const cssMotion = await popupMotionContract(cssPopup); @@ -170,7 +171,7 @@ for (const variant of CASES) { expectPopupMotion(cssMotion); const reference = await snapshotReference(css.root, name); - const tailwindPopup = await openVolumePopover(tailwind.root); + const tailwindPopup = await openVolumePopover(tailwind); const tailwindContract = await popupSurfaceContract(tailwind.root, tailwindPopup); const tailwindSliderContract = await volumeSliderContract(tailwindPopup); const tailwindMotion = await popupMotionContract(tailwindPopup); @@ -219,10 +220,10 @@ for (const variant of CASES) { for (const width of WIDTHS) { const { css, tailwind } = await openVariants(page, variant, width); - await openVolumePopover(css.root); + await openVolumePopover(css); const cssContract = await volumeMaskContract(css.root, width); - await openVolumePopover(tailwind.root); + await openVolumePopover(tailwind); const tailwindContract = await volumeMaskContract(tailwind.root, width); expect(tailwindContract).toEqual(cssContract); @@ -393,7 +394,7 @@ for (const variant of CASES) { const { css, tailwind } = await openVariants(page, variant, 800, media); const cssContract = await enterFullscreen(css.root); - expect(cssContract.previewValueBottomInPreviewHeights).toBe(variant.skin === 'default-video' ? 13.5 : 8); + expect(cssContract.previewValueBottomInPreviewHeights).toBe(variant.skin === 'default-video' ? 11.5 : 6); const reference = await snapshotReference(css.root, name); const cssPreview = variant.skin === 'minimal-video' ? await fullscreenPreviewContract(css.root) : null; @@ -414,8 +415,8 @@ for (const variant of CASES) { expect(tailwindMenu).toEqual({ heightInSpacingUnits: 56, maxHeightInSpacingUnits: 56, scrolls: true }); if (tailwindPreview) { - expect(tailwindPreview.timeToSliderGap).toBeGreaterThanOrEqual(24); - expect(tailwindPreview.timeToThumbnailGap).toBeGreaterThanOrEqual(10); + expect(tailwindPreview.timeToSliderGap).toBeGreaterThanOrEqual(14); + expect(tailwindPreview.timeToThumbnailGap).toBeGreaterThanOrEqual(8); } await exitFullscreen(page); @@ -438,8 +439,8 @@ test('minimal fullscreen geometry scales through the large breakpoints', async ( const cssMenu = await fullscreenSpeedMenuContract(css.root); expect(cssFullscreen.scale).toBe(scale); - expect(cssPreview.timeToSliderGap).toBeGreaterThanOrEqual(30); - expect(cssPreview.timeToThumbnailGap).toBeGreaterThanOrEqual(13); + expect(cssPreview.timeToSliderGap).toBeGreaterThanOrEqual(17); + expect(cssPreview.timeToThumbnailGap).toBeGreaterThanOrEqual(10); expect(cssMenu).toEqual({ heightInSpacingUnits: 56, maxHeightInSpacingUnits: 56, scrolls: true }); await exitFullscreen(page); @@ -482,7 +483,8 @@ for (const skin of ['default-video', 'minimal-video'] as const) { test('semantic CSS stays easy to override from unlayered consumer styles', async ({ page }) => { const { css } = await openVariants(page, REACT_DEFAULT, 800); - await page.addStyleTag({ + // The consumer stylesheet has to land in the frame the player renders in. + await css.frame.addStyleTag({ content: '.media-play-button { width: 44px; height: 44px; background: rgb(18 52 86); }', }); @@ -594,7 +596,8 @@ test('VJSC preserves the shared skin motion contract', async ({ page }) => { for (const variant of CASES) { const { panels } = await openVariants(page, variant, 800); - for (const { root, style } of panels) { + for (const panel of panels) { + const { root, style } = panel; const contract = await sharedMotionContract(root); expect(contract).toEqual({ @@ -650,7 +653,7 @@ test('VJSC preserves the shared skin motion contract', async ({ page }) => { }, }); - const popup = await openVolumePopover(root); + const popup = await openVolumePopover(panel); expectPopupMotion(await popupMotionContract(popup)); } @@ -921,9 +924,12 @@ async function seekDragContract(root: Locator) { await page.mouse.move(pointerX, pointerY); await expect(slider).toHaveAttribute('data-dragging', ''); - const contract = await thumb.evaluate((element, expectedX) => { + // The pointer's offset within the slider: the mouse moved in page coordinates, the thumb reports frame ones. + const contract = await thumb.evaluate((element, expectedOffset) => { const style = getComputedStyle(element); - const slider = [...(element.parentElement?.closest('[data-orientation]')?.querySelectorAll('*') ?? [])]; + const sliderElement = element.parentElement?.closest('[data-orientation]'); + const expectedX = (sliderElement?.getBoundingClientRect().x ?? 0) + expectedOffset; + const slider = [...(sliderElement?.querySelectorAll('*') ?? [])]; const fills = slider .map((target) => getComputedStyle(target)) .filter((style) => @@ -944,7 +950,7 @@ async function seekDragContract(root: Locator) { lag: lag <= 1 ? 0 : Math.ceil(lag), thumbPositionIsImmediate: !positionProperties.has('left') && !positionProperties.has('top'), }; - }, pointerX); + }, box.width * 0.73); await page.mouse.up(); return contract; @@ -1402,9 +1408,20 @@ async function indicatorContract(indicator: Locator) { } async function setDirection(page: Page, direction: 'ltr' | 'rtl') { - await page.locator('html').evaluate((element: HTMLElement, value) => { - element.dir = value; - }, direction); + for (const frame of page.frames()) { + if (frame === page.mainFrame()) continue; + + // The player derives its direction from its locale, so the document alone is not enough: the sandbox pins both. + await frame.evaluate((value) => window.postMessage({ type: 'dir-change', dir: value }, '*'), direction); + + const root = frame.getByRole('group', { name: 'Media player' }); + + await expect(frame.locator('html')).toHaveAttribute('dir', direction); + await expect(root).toHaveCSS('direction', direction); + // The html page renders its player again for the change; bring the controls back before the menus open. + await root.dispatchEvent('pointermove', { pointerType: 'mouse' }); + await expect(root).toHaveAttribute('data-controls-visible', ''); + } } async function enableCaptions({ root, section }: SkinPanel) { @@ -1752,8 +1769,7 @@ async function errorDialogContainmentContract(root: Locator, dialog: Locator) { } async function errorDialogContract(root: Locator, dialog: Locator) { - const rootRect = await root.boundingBox(); - if (!rootRect) throw new Error('Expected the media player to have a rendered box.'); + const rootRect = await frameRect(root); return dialog.evaluate((element: HTMLElement, playerRect) => { const surface = element.querySelector('.media-dialog-popup') ?? element; @@ -2147,15 +2163,16 @@ async function menuHighlightContract(menu: Locator) { })); } -async function openVolumePopover(root: Locator): Promise { +async function openVolumePopover({ frame, root }: SkinPanel): Promise { await root.getByRole('button', { name: 'Mute' }).hover(); const slider = root.getByRole('slider', { name: 'Volume' }); await expect(slider).toBeVisible(); - // The inner locator is evaluated relative to each popover, so it must not carry the root scope. + // The inner locator is evaluated relative to each popover, so it must not carry the root scope, and it has to come + // from the panel's frame. const popup = root .locator('[popover]:visible') - .filter({ has: root.page().getByRole('slider', { name: 'Volume' }) }) + .filter({ has: frame.getByRole('slider', { name: 'Volume' }) }) .first(); await expect(popup).toBeVisible(); @@ -2189,8 +2206,7 @@ async function muteTooltipContract(root: Locator, skin: SkinCase['skin']) { } async function popupSurfaceContract(root: Locator, popup: Locator) { - const rootRect = await root.boundingBox(); - if (!rootRect) throw new Error('Expected the media player to have a rendered box.'); + const rootRect = await frameRect(root); return popup.evaluate((element, playerRect) => { const style = getComputedStyle(element); @@ -2397,8 +2413,7 @@ async function popupMotionContract(popup: Locator) { } async function popupContract(root: Locator, popup: Locator) { - const rootRect = await root.boundingBox(); - if (!rootRect) throw new Error('Expected the media player to have a rendered box.'); + const rootRect = await frameRect(root); return popup.evaluate((element, playerRect) => { const round = (value: number) => Math.round(value * 10) / 10; diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-error-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-error-vjsc-chromium.png index 600bf3c4e6..5d7ed50853 100644 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-error-vjsc-chromium.png and b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-default-video-error-vjsc-chromium.png differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-error-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-error-vjsc-chromium.png index 97929b28f8..899390b503 100644 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-error-vjsc-chromium.png and b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/html-minimal-video-error-vjsc-chromium.png differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-error-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-error-vjsc-chromium.png index 8cbbf9a448..66b5f6f7a2 100644 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-error-vjsc-chromium.png and b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-default-video-error-vjsc-chromium.png differ diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-error-vjsc-chromium.png b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-error-vjsc-chromium.png index d41e46ec33..1cea2c5874 100644 Binary files a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-error-vjsc-chromium.png and b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts-snapshots/react-minimal-video-error-vjsc-chromium.png differ diff --git a/apps/e2e/vite.config.ts b/apps/e2e/vite.config.ts index 82fed1d0c0..4c718a9e81 100644 --- a/apps/e2e/vite.config.ts +++ b/apps/e2e/vite.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ }, 'test:skin-parity': { command: 'playwright test --config suites/skin-parity/playwright.config.ts', - dependsOn: [...workspaceTaskDependencies(), '@videojs/skins#generate'], + dependsOn: [...workspaceTaskDependencies(), '@videojs/sandbox#setup', '@videojs/skins#generate'], cache: false, }, 'test:sandbox': { diff --git a/apps/sandbox/README.md b/apps/sandbox/README.md index 2a2743df66..9d9e7bc77e 100644 --- a/apps/sandbox/README.md +++ b/apps/sandbox/README.md @@ -10,9 +10,21 @@ pnpm dev:sandbox # sandbox + workspace package watch pnpm dev # also runs the docs site ``` -Open the printed URL. The root route renders an interactive shell — a navbar with dropdowns for platform (HTML, React, CDN), preset (`video`, `hlsjs-video`, `audio`, etc.), skin, styling (CSS or Tailwind), and source — that previews the selected combination in an iframe. Use the **Open** button to pop the preview out into its own tab. +Open the printed URL. The root route renders an interactive shell — a navbar with dropdowns for platform (HTML, React, CDN), media (`video`, `hlsjs-video`, `audio`, etc.), and source, with the skin, its styling (CSS or Tailwind), where it comes from, and what to compare it against in the row above the preview — that previews the selected combination in an iframe. Every selection is in the URL, so a link reproduces a preview. Use the **Open** button to pop the preview out into its own tab. -**Language** is in **Player settings** (gear icon) for every preset (HTML, React, and CDN). **CDN** registers copy through `@videojs/html/cdn/i18n` (the same registry as the CDN player bundle), not source `@videojs/html/i18n`. After pulling template changes, restart `pnpm dev:sandbox` so `scripts/setup.ts` refreshes `src/` from `templates/`. +`app/media.ts` describes each media once: its label, player, element, which sources the picker offers, and which controls apply. The shell derives its constraints from that table, and the CDN page picks its bundles by the same id. Older links that say `?preset=` still resolve; `preset` now means the player preset a skin is built for (`video`, `audio`, `live-video`, `live-audio`). + +**Skins from** picks where a skin's code and styles come from: the framework packages (`@videojs/html`, `@videojs/react`; CSS only), the Shadcn registry installed into `app/_generated` by setup (CSS for both platforms, Tailwind for React), or the authored sources under `packages/skins/src`, compiled on request by the skins' Vite preset and offered only inside the workspace. `vite.workspace.config.ts` adds that preset for the `dev` and `build` tasks; `vite.config.ts` itself never imports the compiler, because Vite+ reads it to schedule tasks before anything is built. Authored skins pick up edits to `packages/skins/src` live, and they go through the built `vjsc` package, so rebuild it after a compiler change. Until you pick one, CSS comes from the packages and Tailwind from the registry, which is what the sandbox always did. The choice travels as `skins` in the URL. + +**Compare** renders two previews that differ on one axis and share everything else: CSS against Tailwind, one skin source against the next that can load the styling, Default against Minimal, or HTML against React. Each panel is its own iframe, so two stylesheets never share a document, and its header names the value it takes. The panels sit side by side once the preview is wide enough and stack below that; the toggle above them forces either. **Mirror playback**, a checkbox beside the layout toggle, carries play, pause, seeks, volume, mute, playback rate, and caption selection from the panel you touch to the other one; it moves state through the media element rather than pointer positions, so it works across skins and platforms. The URL carries `compare`, `layout`, and `mirror`. + +**Report** copies a markdown summary for a bug report and shows it in a dialog: the URL, the branch and commit the sandbox was served from, the selection in words, each panel's URL when comparing, browser, viewport, the detected preferences, and the last errors the preview frames relayed (uncaught errors, unhandled rejections, and `console.error`). The same preference badges sit at the bottom of the **Options** panel and follow DevTools' rendering emulation live. + +**Width** sits at the top of the **Options** panel (the sliders icon in the navbar opens and closes it, and the choice is remembered), as a slider up to 1360px with a field for an exact value, and sizes the player through `--sandbox-player-width`; until it is touched, a preview opens at its skin's own width. **Color scheme** and **Direction** are in the same panel and pin the preview's `color-scheme` and `dir`, where the defaults follow the operating system and the locale. All three travel in the URL (`width`, `scheme`, `dir`), so a direct page honours them too. + +**Captions**, under **Playback** in the **Options** panel, adds one or two subtitle tracks to a video so the captions menu has something to show; the tracks are the page's, so no template spells them out. The source picker also carries a **Missing file** entry that opens the player's error dialog without waiting on a network. + +**Language** is in the **Options** panel for every media (HTML, React, and CDN). **CDN** registers copy through `@videojs/html/cdn/i18n` (the same registry as the CDN player bundle), not source `@videojs/html/i18n`. After pulling template changes, restart `pnpm dev:sandbox` so `scripts/setup.ts` refreshes `src/` from `templates/`. The shell covers the main combinatorial matrix. One-off templates not in that matrix (e.g. `firefox-mse-repro`, `spf-segment-loading`, `hls-video-html`) are reachable by navigating directly to `//`. See `apps/sandbox/templates/` for the full list. @@ -71,13 +83,13 @@ This previews every change first and prompts before doing anything. It overwrite Every pull request publishes this directory as a StackBlitz template through [pkg.pr.new](https://github.com/stackblitz-labs/pkg.pr.new), booting it against that commit's preview packages. That makes the sandbox the one app here that must also run as a standalone project, which constrains it in two ways: -- **Nothing may reference a path outside this directory.** `vite.config.ts` locates the prebuilt `@videojs/html` CDN bundle through Node resolution rather than `../../packages/html`, and `tsconfig.json` is self-contained instead of extending `../../tsconfig.base.json` — Vite fails to start if that `extends` cannot be resolved. -- **Only published packages may be dependencies.** CSS skins come from `@videojs/html` and `@videojs/react`. Setup uses the stock Shadcn CLI to install ignored source-owned Tailwind skins from the local built registry in the monorepo or `https://shadcn.videojs.org/r` elsewhere. -- **The package manager has to be declared here.** Only the repo root says pnpm, and the root is never uploaded, so StackBlitz would otherwise default to npm. The `stackblitz` field in `package.json` turns off its automatic install and boots with pnpm instead. `--ignore-scripts` is there because pnpm refuses to silently skip dependency build scripts and fails the install if it has to; the sandbox needs none of them, esbuild's native binary included. +- **Nothing may reference a path outside this directory.** `vite.config.ts` locates the prebuilt `@videojs/html` CDN bundle through Node resolution rather than `../../packages/html`, carries its own copies of the task helpers from `build/task.ts`, and `tsconfig.json` is self-contained instead of extending `../../tsconfig.base.json` — Vite fails to start if a config import or that `extends` cannot be resolved. The one project file that does extend the base, `tsconfig.shared.json`, sits beside it under a name the transformer never looks up, so the shared sources still compile against `tsconfig.json`. The one exception is `vite.workspace.config.ts`, which imports the skins preset from `packages/skins`; the tasks only name it when that directory exists, so StackBlitz never loads it. Tasks that build sibling packages, such as `@videojs/html#build:cdn`, are likewise only declared when `pnpm-workspace.yaml` exists two directories up. +- **Only published packages may be dependencies.** Package skins come from `@videojs/html` and `@videojs/react`. Setup uses the stock Shadcn CLI to install the ignored registry skins, three catalogs of eight, from the local built registry in the monorepo or `https://shadcn.videojs.org/r` elsewhere. The one private dependency, `@videojs/icons`, exists so authored skins dedupe onto a single copy inside the workspace; `scripts/prepare-template.ts`, which CI runs right before the upload, drops private workspace dependencies and inlines the `catalog:` versions from `pnpm-workspace.yaml`, since a lone `package.json` can resolve neither. When the hosted registry cannot be reached, setup carries on without the registry skins and the shell offers the package skins only. +- **The package manager has to be declared here.** Only the repo root says pnpm, and the root is never uploaded, so StackBlitz would otherwise default to npm. The `stackblitz` field in `package.json` turns off its automatic install and boots with pnpm instead and runs the `dev` task through `vp run dev`; Vite+ owns the task names, so `package.json` declares no `dev` script. `--ignore-scripts` is there because pnpm refuses to silently skip dependency build scripts and fails the install if it has to; the sandbox needs none of them, esbuild's native binary included. `--config.blockExoticSubdeps=false` is there because the preview packages depend on each other through pkg.pr.new URLs, which pnpm 11 otherwise refuses to follow from inside a dependency. The task runs with `--config.verifyDepsBeforeRun=false` because pnpm would otherwise re-run the install with its default settings first, and that install fails on the very build scripts the first one skipped. - **Every cross-origin subresource has to be CORS-enabled.** StackBlitz previews are cross-origin isolated (`Cross-Origin-Embedder-Policy: require-corp`), so a no-CORS load from `stream.mux.com` or `image.mux.com` is blocked outright — neither host sends `Cross-Origin-Resource-Policy`. That is why every media element here carries a bare `crossorigin` (the CORS-settings attribute treats it as `anonymous`), which also puts the storyboard `` into CORS mode and, through it, the thumbnail sprites. The poster has to be the template's own image — an HTML skin renders one only where you slot it, and a React skin left to itself renders an `` no prop can reach — so the HTML templates slot `` and the React templates hand one to `renderPoster`. Both still pass the URL through the player and let the poster fill in the `src`; an image carrying its own would opt out of the blur-up load state. React has no bare-attribute form, so its templates write `crossOrigin=""`. One thing stays broken in a preview and cannot be fixed from here: a CSS `url()` can never be CORS-enabled, so the `placeholdersrc` blur-up does not render. - **Tailwind scans source installed into the app.** This exercises the supported registry workflow instead of package-internal utility classes or docs-only templates. `app/_generated/` is reproducible, gitignored, and covered by `app/styles.css`. -`src/` is gitignored and so never part of the upload. That is fine: the `dev` script runs `setup.ts` first, which recreates `src/` from `templates/` on boot. +`src/` is gitignored and so never part of the upload. That is fine: the `dev` task runs `setup.ts` first, which recreates `src/` from `templates/` on boot. ## Adding a new sandbox diff --git a/apps/sandbox/app/compare.ts b/apps/sandbox/app/compare.ts new file mode 100644 index 0000000000..2b0ecd1297 --- /dev/null +++ b/apps/sandbox/app/compare.ts @@ -0,0 +1,147 @@ +import { SKIN_SOURCES } from '@app/constants'; +import { PLATFORM_LABELS, SKIN_LABELS, SKIN_SOURCE_LABELS, SKIN_SOURCE_PHRASES, STYLING_LABELS } from '@app/labels'; +import { hasSkinChoice, hasTailwindSkin, MEDIA, type MediaId } from '@app/media'; +import { defaultSkinSource, skinSourceAvailable, skinStylings, tailwindSkinAvailable } from '@app/shared/skin-sources'; +import { SOURCES, type SourceId } from '@app/shared/sources'; +import type { Platform, Skin, SkinSource, Styling } from '@app/types'; + +/** The one axis two compare panels differ on; every other selection is shared between them. */ +export const COMPARE_AXES = ['styling', 'skins', 'skin', 'platform'] as const; +export type CompareAxis = (typeof COMPARE_AXES)[number]; +export type CompareMode = 'off' | CompareAxis; + +/** `auto` puts the panels side by side once the preview is wide enough for two players and stacks them below that. */ +export const COMPARE_LAYOUTS = ['auto', 'row', 'column'] as const; +export type CompareLayout = (typeof COMPARE_LAYOUTS)[number]; + +/** The shell's skin selections; `skins` is what the user chose, or nothing for the styling's default. */ +export interface SkinSelection { + readonly platform: Platform; + readonly styling: Styling; + readonly skins: SkinSource | undefined; + readonly skin: Skin; + readonly media: MediaId; +} + +/** What one frame renders. Unlike the shell's selection, `skins` is resolved. */ +export interface ComparePanel { + /** The value this panel takes on the compared axis, or `single`; names the frame in the DOM and in specs. */ + readonly id: string; + /** The value this panel takes on the compared axis, as its header shows it. Empty for a lone panel. */ + readonly label: string; + readonly platform: Platform; + readonly styling: Styling; + readonly skins: SkinSource; + readonly skin: Skin; +} + +/** The source a panel loads: the explicit choice when it publishes the styling on that platform, else the default. */ +export function resolveSkinSource(platform: Platform, styling: Styling, skins: SkinSource | undefined): SkinSource { + if (skins !== undefined && skinSourceAvailable(skins, platform) && skinStylings(platform, skins).includes(styling)) { + return skins; + } + + return defaultSkinSource(platform, styling); +} + +/** The next source after `current` that can load this styling here, in menu order; what a source comparison shows. */ +export function otherSkinSource(platform: Platform, styling: Styling, current: SkinSource): SkinSource | undefined { + return SKIN_SOURCES.find( + (source) => + source !== current && skinSourceAvailable(source, platform) && skinStylings(platform, source).includes(styling) + ); +} + +/** Whether a styling can be shown at all for the media on the platform. */ +function stylingAvailable(styling: Styling, platform: Platform, media: MediaId): boolean { + return styling === 'css' || (hasTailwindSkin(media, platform) && tailwindSkinAvailable(platform)); +} + +function panel( + id: string, + label: string, + platform: Platform, + styling: Styling, + skins: SkinSource | undefined, + skin: Skin +): ComparePanel { + return { id, label, platform, styling, skins: resolveSkinSource(platform, styling, skins), skin }; +} + +/** Whether an axis has two values to show for the current selection. */ +export function compareAvailable(axis: CompareAxis, selection: SkinSelection): boolean { + const { platform, styling, skins, skin, media } = selection; + + switch (axis) { + case 'styling': + return stylingAvailable('tailwind', platform, media); + case 'skins': + return ( + hasSkinChoice(media) && + otherSkinSource(platform, styling, resolveSkinSource(platform, styling, skins)) !== undefined + ); + case 'skin': + return hasSkinChoice(media) && skin !== undefined; + // Every media has an html and a react page, so the CDN page compares those two. + case 'platform': + return true; + } +} + +/** The frames to render: one for the selection, or two that differ on the compared axis. */ +export function comparePanels(selection: SkinSelection, compare: CompareMode): readonly ComparePanel[] { + const { platform, styling, skins, skin, media } = selection; + + switch (compare) { + case 'off': + return [panel('single', '', platform, styling, skins, skin)]; + case 'styling': + return (['css', 'tailwind'] as const).map((value) => + panel(value, STYLING_LABELS[value], platform, value, skins, skin) + ); + case 'skins': { + const current = resolveSkinSource(platform, styling, skins); + const other = otherSkinSource(platform, styling, current) ?? current; + + return [current, other].map((value) => panel(value, SKIN_SOURCE_LABELS[value], platform, styling, value, skin)); + } + case 'skin': + return (['default', 'minimal'] as const).map((value) => + panel(value, SKIN_LABELS[value], platform, styling, skins, value) + ); + case 'platform': + return (['html', 'react'] as const).map((value) => { + // The other platform may not publish this styling; it shows CSS rather than nothing. + const panelStyling = stylingAvailable(styling, value, media) ? styling : 'css'; + + return panel(value, PLATFORM_LABELS[value], value, panelStyling, skins, skin); + }); + } +} + +export interface SelectionSummary { + readonly platform: Platform; + readonly media: MediaId; + readonly skin: Skin; + readonly styling: Styling; + readonly skins: SkinSource; + readonly width: number; + readonly source: SourceId; +} + +/** The whole selection in words, so the preview states what it shows and a report can quote it. */ +export function summarizeSelection(summary: SelectionSummary): string { + const descriptor = MEDIA[summary.media]; + const skinned = descriptor.player !== 'background'; + const parts = [ + PLATFORM_LABELS[summary.platform], + descriptor.label, + ...(skinned + ? [SKIN_LABELS[summary.skin], STYLING_LABELS[summary.styling], SKIN_SOURCE_PHRASES[summary.skins]] + : []), + ...(skinned ? [`${summary.width}px`] : []), + descriptor.fixedSource ? 'fixed source' : SOURCES[summary.source].label, + ]; + + return parts.join(' · '); +} diff --git a/apps/sandbox/app/constants.ts b/apps/sandbox/app/constants.ts index 07600cb13c..e7f0fb3819 100644 --- a/apps/sandbox/app/constants.ts +++ b/apps/sandbox/app/constants.ts @@ -1,41 +1,5 @@ export const SKINS = ['default', 'minimal'] as const; export const PLATFORMS = ['html', 'react', 'cdn'] as const; export const STYLINGS = ['css', 'tailwind'] as const; -export const PRESETS = [ - 'video', - 'hlsjs-video', - 'native-hls-video', - 'mux-video', - 'mux-video-spf', - 'mux-audio', - 'mux-audio-spf', - 'hls-video', - 'hls-audio', - 'dash-video', - 'shaka-video', - 'audio', - 'background-video', - 'hls-background-video', - 'mux-background-video', - 'vimeo-video', - 'youtube-video', - 'cloudflare-video', - 'spotify-audio', - 'tiktok-video', - 'twitch-video', - 'wistia-video', -] as const; - -/** - * Presets that hand playback to a third-party embed. They render one fixed source rather than the source picker's list, - * and have no Tailwind skin variant, so the navbar disables both controls for them. - */ -export const EMBED_PRESETS = [ - 'vimeo-video', - 'youtube-video', - 'cloudflare-video', - 'spotify-audio', - 'tiktok-video', - 'twitch-video', - 'wistia-video', -] as const; +/** Where a skin's code and styles come from: the framework packages, a Shadcn registry install, or the authored sources. */ +export const SKIN_SOURCES = ['package', 'registry', 'authored'] as const; diff --git a/apps/sandbox/app/env.d.ts b/apps/sandbox/app/env.d.ts new file mode 100644 index 0000000000..69ccd308bf --- /dev/null +++ b/apps/sandbox/app/env.d.ts @@ -0,0 +1,22 @@ +/** + * True when `packages/skins` is checked out beside the sandbox, so authored skins can be compiled. Set by + * `vite.config.ts`. + */ +declare const __WORKSPACE_SKINS__: boolean; +declare const __REGISTRY_SKINS__: boolean; + +/** The checkout the sandbox was served from, for the copied report; `unknown` where there is no git metadata. */ +declare const __SANDBOX_BRANCH__: string; +declare const __SANDBOX_COMMIT__: string; + +declare module '*.css'; + +// Authored skin modules, addressed by the compiler query; their exports are checked at runtime by name. +declare module '*&skin=default-video'; +declare module '*&skin=minimal-video'; +declare module '*&skin=default-live-video'; +declare module '*&skin=minimal-live-video'; +declare module '*&skin=default-live-audio'; +declare module '*&skin=minimal-live-audio'; +declare module '*&skin=default-audio'; +declare module '*&skin=minimal-audio'; diff --git a/apps/sandbox/app/labels.ts b/apps/sandbox/app/labels.ts new file mode 100644 index 0000000000..618d875339 --- /dev/null +++ b/apps/sandbox/app/labels.ts @@ -0,0 +1,45 @@ +import type { CompareLayout, CompareMode } from '@app/compare'; +import type { Platform, Skin, SkinSource, Styling } from '@app/types'; + +export const PLATFORM_LABELS: Record = { + html: 'HTML', + react: 'React', + cdn: 'CDN', +}; + +export const STYLING_LABELS: Record = { + css: 'CSS', + tailwind: 'Tailwind', +}; + +export const SKIN_LABELS: Record = { + default: 'Default', + minimal: 'Minimal', +}; + +export const SKIN_SOURCE_LABELS: Record = { + package: 'Framework package', + registry: 'Shadcn registry', + authored: 'Authored source', +}; + +/** The source as a phrase inside a sentence, such as `Minimal · Tailwind · from the registry`. */ +export const SKIN_SOURCE_PHRASES: Record = { + package: 'from the package', + registry: 'from the registry', + authored: 'authored', +}; + +export const COMPARE_LABELS: Record = { + off: 'Off', + styling: 'CSS vs Tailwind', + skins: 'Skin sources', + skin: 'Default vs Minimal', + platform: 'HTML vs React', +}; + +export const LAYOUT_LABELS: Record = { + auto: 'Auto', + row: 'Side by side', + column: 'Stacked', +}; diff --git a/apps/sandbox/app/media.ts b/apps/sandbox/app/media.ts new file mode 100644 index 0000000000..378f5b3dd8 --- /dev/null +++ b/apps/sandbox/app/media.ts @@ -0,0 +1,303 @@ +import { + BACKGROUND_VIDEO_SRC, + CLOUDFLARE_VIDEO_SRC, + DASH_SOURCE_IDS, + DEFAULT_BACKGROUND_SOURCE, + DEFAULT_DASH_SOURCE, + HLS_SOURCE_IDS, + MUX_SOURCE_IDS, + MUX_SPF_SOURCE_IDS, + NON_DASH_SOURCE_IDS, + type SandboxSource, + SHAKA_SOURCE_IDS, + SOURCE_IDS, + type SourceId, + SOURCES, + SPF_HLS_SOURCE_IDS, + SPOTIFY_AUDIO_SRC, + TIKTOK_VIDEO_SRC, + TWITCH_VIDEO_SRC, + VIMEO_VIDEO_SRC, + WISTIA_VIDEO_SRC, + YOUTUBE_VIDEO_SRC, +} from './shared/sources'; +import type { Platform } from './types'; + +/** The player a media mounts in. The live video and audio players take over when `live` is set and the source is. */ +export type MediaPlayer = 'video' | 'audio' | 'background'; + +/** + * One media engine the sandbox can demo: what it is called, where it mounts, and how the shell constrains the other + * selections around it. The html and react platforms have a template per entry named `-`; the CDN page + * picks its bundles by the same id. + */ +export interface MediaDescriptor { + readonly label: string; + readonly player: MediaPlayer; + /** The element the media renders as, which is also the name of its CDN bundle. */ + readonly tag: string; + /** The live player and skin variants apply while the selected source is live. */ + readonly live?: true; + /** Hands playback to a third-party player in a cross-origin frame, so the settings menu has nothing to attach to. */ + readonly embed?: true; + /** One URL the media always plays, in place of the source picker. */ + readonly fixedSource?: string; + /** Sources the picker offers. */ + readonly sources: readonly SourceId[]; + /** Sources the picker offers on the CDN page, which builds elements from attributes alone. */ + readonly cdnSources?: readonly SourceId[]; + /** Where the picker falls back when the current source is not offered. */ + readonly fallbackSource?: SourceId; + /** Where the picker lands whenever this media is entered without an explicit source. */ + readonly entrySource?: SourceId; + /** What the media will do with a source, when that is worth labelling for someone smoke-testing. */ + readonly outcome?: (source: SandboxSource) => string | undefined; +} + +/** + * Any HLS source, including formats the SPF engine can't play — reaching those failures on purpose is how the error + * paths get smoke-tested. The empty-src entry carries no media, so it is offered wherever the media can render one. + */ +const SPF_MEDIA_SOURCE_IDS = SPF_HLS_SOURCE_IDS.filter( + (id) => SOURCES[id].type === 'hls' || SOURCES[id].type === 'none' +); + +/** + * The plain HLS media are the SPF engine: no TS transmux pipeline and no EME, so it refuses MPEG-TS on format and + * encrypted renditions on protection. Derived from the pair rather than stored on the source, since every source here + * plays fine under some other media. + * + * The video and audio-only variants answer differently, and a note promising the wrong outcome is worse than none — a + * reviewer would file the difference as a bug: + * + * - **DRM.** Mux encrypts video renditions and leaves audio clear. The audio-only engine resolves only the audio + * rendition, so it never fetches an encrypted playlist and plays the source instead of refusing it. + * - **MPEG-TS.** Under audio-only, which specific failure depends on whether the source carries an audio rendition of its + * own or muxes audio into its video renditions — an absent type reports nothing and stalls silently rather than + * surfacing a verdict (see `internal/design/spf/features/errors.md`). Both mean nothing plays, so the note stops at + * that rather than naming a verdict that only appears for one of them. + */ +function spfOutcome(audioOnly: boolean) { + return (source: SandboxSource): string | undefined => { + if (source.drm) return audioOnly ? 'plays — Mux leaves audio clear' : 'expects protected error'; + + if (source.subType && source.subType !== 'mp4') { + return audioOnly ? 'expects no playback' : 'expects unsupported-format error'; + } + + return undefined; + }; +} + +/** + * The background media are the same engine again, error surface included: `collectErrors` is composed, the one-shot + * selection carries capability constraints, and the adapter promotes the first fatal condition. Nothing reaches the + * media element even so — MPEG-TS and encryption both leave `HTMLMediaElement.error` null, measured on Chromium and + * WebKit — so that promoted condition is the only signal there is. Kept apart from the plain HLS note because this + * composition's fatal set is wider: it is video-only, so an absent video type is fatal here too. + */ +function backgroundOutcome(source: SandboxSource): string | undefined { + if (source.drm) return 'expects protected error'; + + if (source.subType && source.subType !== 'mp4') return 'expects unsupported-format error'; + + return undefined; +} + +const MEDIA_MAP = { + video: { label: 'Video', player: 'video', tag: 'video', sources: NON_DASH_SOURCE_IDS }, + // `` is the only media that turns a Mux DRM token into license URLs; the HLS media take license servers + // through `source.drm`, whichever path they play. The CDN page builds elements from attributes alone, so neither + // reaches it. + 'hlsjs-video': { + label: 'HLS Video (hls.js)', + player: 'video', + tag: 'hlsjs-video', + live: true, + sources: HLS_SOURCE_IDS, + cdnSources: NON_DASH_SOURCE_IDS, + }, + 'native-hls-video': { + label: 'Native HLS Video', + player: 'video', + tag: 'native-hls-video', + live: true, + sources: HLS_SOURCE_IDS, + cdnSources: NON_DASH_SOURCE_IDS, + }, + 'mux-video': { + label: 'Mux Video', + player: 'video', + tag: 'mux-video', + live: true, + sources: MUX_SOURCE_IDS, + cdnSources: NON_DASH_SOURCE_IDS, + }, + 'mux-video-spf': { + label: 'Mux Video (SPF)', + player: 'video', + tag: 'mux-video', + live: true, + sources: MUX_SPF_SOURCE_IDS, + cdnSources: SPF_HLS_SOURCE_IDS, + }, + 'mux-audio': { + label: 'Mux Audio', + player: 'audio', + tag: 'mux-audio', + live: true, + sources: MUX_SOURCE_IDS, + cdnSources: NON_DASH_SOURCE_IDS, + }, + 'mux-audio-spf': { + label: 'Mux Audio (SPF)', + player: 'audio', + tag: 'mux-audio', + live: true, + sources: MUX_SPF_SOURCE_IDS, + cdnSources: SPF_HLS_SOURCE_IDS, + }, + 'hls-video': { + label: 'HLS Video', + player: 'video', + tag: 'hls-video', + live: true, + sources: SPF_MEDIA_SOURCE_IDS, + outcome: spfOutcome(false), + }, + 'hls-audio': { + label: 'HLS Audio', + player: 'audio', + tag: 'hls-audio', + live: true, + sources: SPF_MEDIA_SOURCE_IDS, + outcome: spfOutcome(true), + }, + 'dash-video': { + label: 'DASH Video', + player: 'video', + tag: 'dash-video', + sources: DASH_SOURCE_IDS, + fallbackSource: DEFAULT_DASH_SOURCE, + }, + // Shaka plays DASH and HLS from the same element, so it is the one media offered both. + 'shaka-video': { label: 'Shaka Video', player: 'video', tag: 'shaka-video', sources: SHAKA_SOURCE_IDS }, + audio: { label: 'Audio', player: 'audio', tag: 'audio', sources: SOURCE_IDS }, + // `` hands a progressive MP4 to the browser, so its source stays fixed. The SPF-backed pair stream + // whatever manifest they are pointed at, and land on the 4K ladder when entered rather than inheriting the global + // default, which is MPEG-TS and so a failure case for that engine rather than a demo of it. + 'background-video': { + label: 'Background Video', + player: 'background', + tag: 'background-video', + fixedSource: BACKGROUND_VIDEO_SRC, + sources: NON_DASH_SOURCE_IDS, + }, + 'hls-background-video': { + label: 'HLS Background Video (SPF)', + player: 'background', + tag: 'hls-background-video', + sources: SPF_MEDIA_SOURCE_IDS, + entrySource: DEFAULT_BACKGROUND_SOURCE, + outcome: backgroundOutcome, + }, + 'mux-background-video': { + label: 'Mux Background Video (SPF)', + player: 'background', + tag: 'mux-background-video', + sources: SPF_MEDIA_SOURCE_IDS, + entrySource: DEFAULT_BACKGROUND_SOURCE, + outcome: backgroundOutcome, + }, + // Each embed renders one provider page URL rather than the picker's list. + 'vimeo-video': { + label: 'Vimeo Video', + player: 'video', + tag: 'vimeo-video', + embed: true, + fixedSource: VIMEO_VIDEO_SRC, + sources: NON_DASH_SOURCE_IDS, + }, + 'youtube-video': { + label: 'YouTube Video', + player: 'video', + tag: 'youtube-video', + embed: true, + fixedSource: YOUTUBE_VIDEO_SRC, + sources: NON_DASH_SOURCE_IDS, + }, + 'cloudflare-video': { + label: 'Cloudflare Stream Video', + player: 'video', + tag: 'cloudflare-video', + embed: true, + fixedSource: CLOUDFLARE_VIDEO_SRC, + sources: NON_DASH_SOURCE_IDS, + }, + 'spotify-audio': { + label: 'Spotify Audio', + player: 'audio', + tag: 'spotify-audio', + embed: true, + fixedSource: SPOTIFY_AUDIO_SRC, + sources: NON_DASH_SOURCE_IDS, + }, + 'tiktok-video': { + label: 'TikTok Video', + player: 'video', + tag: 'tiktok-video', + embed: true, + fixedSource: TIKTOK_VIDEO_SRC, + sources: NON_DASH_SOURCE_IDS, + }, + 'twitch-video': { + label: 'Twitch Video', + player: 'video', + tag: 'twitch-video', + embed: true, + fixedSource: TWITCH_VIDEO_SRC, + sources: NON_DASH_SOURCE_IDS, + }, + 'wistia-video': { + label: 'Wistia Video', + player: 'video', + tag: 'wistia-video', + embed: true, + fixedSource: WISTIA_VIDEO_SRC, + sources: NON_DASH_SOURCE_IDS, + }, +} satisfies Record; + +export type MediaId = keyof typeof MEDIA_MAP; + +// Annotated rather than `as const`, so indexing by a `MediaId` yields the one descriptor shape. +export const MEDIA: Record = MEDIA_MAP; + +/** Menu order. */ +export const MEDIA_IDS = Object.keys(MEDIA_MAP) as MediaId[]; + +export function isMediaId(value: string | null | undefined): value is MediaId { + return value != null && Object.hasOwn(MEDIA_MAP, value); +} + +/** The sources the picker offers for a media on a platform. */ +export function mediaSources(media: MediaId, platform: Platform): readonly SourceId[] { + const { sources, cdnSources } = MEDIA[media]; + + return platform === 'cdn' ? (cdnSources ?? sources) : sources; +} + +/** + * The CDN bundles ship CSS skins only, the background skin is one element with no variants, and an embed's provider + * frame has nothing for a Tailwind skin to style. + */ +export function hasTailwindSkin(media: MediaId, platform: Platform): boolean { + const { embed, player } = MEDIA[media]; + + return platform !== 'cdn' && player !== 'background' && embed !== true; +} + +/** The background skin is one element with no default or minimal variant to choose between. */ +export function hasSkinChoice(media: MediaId): boolean { + return MEDIA[media].player !== 'background'; +} diff --git a/apps/sandbox/app/shared/authored-skins.ts b/apps/sandbox/app/shared/authored-skins.ts new file mode 100644 index 0000000000..7484161702 --- /dev/null +++ b/apps/sandbox/app/shared/authored-skins.ts @@ -0,0 +1,110 @@ +import type { Skin, Styling } from '@app/types'; + +import type { SkinPreset } from './html/skin-tags'; + +type AuthoredKey = `${'react' | 'html'}/${SkinPreset}/${Skin}/${Styling}`; + +/** + * The authored skins, compiled on request by the skins' Vite preset. Each query names the styling, the render target, + * and the skin, so the compiler can transform one module into any of the four outputs. Only the workspace has these + * files; the loaders are reached through a dynamic import that nothing outside it ever follows. + */ +const authoredSkins = { + 'react/video/default/css': () => + import('../../../../packages/skins/src/skins/default-video/skin.tsx?style=css&target=react&skin=default-video'), + 'react/video/default/tailwind': () => + import('../../../../packages/skins/src/skins/default-video/skin.tsx?style=tailwind&target=react&skin=default-video'), + 'react/video/minimal/css': () => + import('../../../../packages/skins/src/skins/minimal-video/skin.tsx?style=css&target=react&skin=minimal-video'), + 'react/video/minimal/tailwind': () => + import('../../../../packages/skins/src/skins/minimal-video/skin.tsx?style=tailwind&target=react&skin=minimal-video'), + 'react/live-video/default/css': () => + import('../../../../packages/skins/src/skins/default-live-video/skin.tsx?style=css&target=react&skin=default-live-video'), + 'react/live-video/default/tailwind': () => + import('../../../../packages/skins/src/skins/default-live-video/skin.tsx?style=tailwind&target=react&skin=default-live-video'), + 'react/live-video/minimal/css': () => + import('../../../../packages/skins/src/skins/minimal-live-video/skin.tsx?style=css&target=react&skin=minimal-live-video'), + 'react/live-video/minimal/tailwind': () => + import('../../../../packages/skins/src/skins/minimal-live-video/skin.tsx?style=tailwind&target=react&skin=minimal-live-video'), + 'react/audio/default/css': () => + import('../../../../packages/skins/src/skins/default-audio/skin.tsx?style=css&target=react&skin=default-audio'), + 'react/audio/default/tailwind': () => + import('../../../../packages/skins/src/skins/default-audio/skin.tsx?style=tailwind&target=react&skin=default-audio'), + 'react/audio/minimal/css': () => + import('../../../../packages/skins/src/skins/minimal-audio/skin.tsx?style=css&target=react&skin=minimal-audio'), + 'react/audio/minimal/tailwind': () => + import('../../../../packages/skins/src/skins/minimal-audio/skin.tsx?style=tailwind&target=react&skin=minimal-audio'), + 'react/live-audio/default/css': () => + import('../../../../packages/skins/src/skins/default-live-audio/skin.tsx?style=css&target=react&skin=default-live-audio'), + 'react/live-audio/default/tailwind': () => + import('../../../../packages/skins/src/skins/default-live-audio/skin.tsx?style=tailwind&target=react&skin=default-live-audio'), + 'react/live-audio/minimal/css': () => + import('../../../../packages/skins/src/skins/minimal-live-audio/skin.tsx?style=css&target=react&skin=minimal-live-audio'), + 'react/live-audio/minimal/tailwind': () => + import('../../../../packages/skins/src/skins/minimal-live-audio/skin.tsx?style=tailwind&target=react&skin=minimal-live-audio'), + 'html/video/default/css': () => + import('../../../../packages/skins/src/skins/default-video/skin.tsx?style=css&target=html&skin=default-video'), + 'html/video/default/tailwind': () => + import('../../../../packages/skins/src/skins/default-video/skin.tsx?style=tailwind&target=html&skin=default-video'), + 'html/video/minimal/css': () => + import('../../../../packages/skins/src/skins/minimal-video/skin.tsx?style=css&target=html&skin=minimal-video'), + 'html/video/minimal/tailwind': () => + import('../../../../packages/skins/src/skins/minimal-video/skin.tsx?style=tailwind&target=html&skin=minimal-video'), + 'html/live-video/default/css': () => + import('../../../../packages/skins/src/skins/default-live-video/skin.tsx?style=css&target=html&skin=default-live-video'), + 'html/live-video/default/tailwind': () => + import('../../../../packages/skins/src/skins/default-live-video/skin.tsx?style=tailwind&target=html&skin=default-live-video'), + 'html/live-video/minimal/css': () => + import('../../../../packages/skins/src/skins/minimal-live-video/skin.tsx?style=css&target=html&skin=minimal-live-video'), + 'html/live-video/minimal/tailwind': () => + import('../../../../packages/skins/src/skins/minimal-live-video/skin.tsx?style=tailwind&target=html&skin=minimal-live-video'), + 'html/audio/default/css': () => + import('../../../../packages/skins/src/skins/default-audio/skin.tsx?style=css&target=html&skin=default-audio'), + 'html/audio/default/tailwind': () => + import('../../../../packages/skins/src/skins/default-audio/skin.tsx?style=tailwind&target=html&skin=default-audio'), + 'html/audio/minimal/css': () => + import('../../../../packages/skins/src/skins/minimal-audio/skin.tsx?style=css&target=html&skin=minimal-audio'), + 'html/audio/minimal/tailwind': () => + import('../../../../packages/skins/src/skins/minimal-audio/skin.tsx?style=tailwind&target=html&skin=minimal-audio'), + 'html/live-audio/default/css': () => + import('../../../../packages/skins/src/skins/default-live-audio/skin.tsx?style=css&target=html&skin=default-live-audio'), + 'html/live-audio/default/tailwind': () => + import('../../../../packages/skins/src/skins/default-live-audio/skin.tsx?style=tailwind&target=html&skin=default-live-audio'), + 'html/live-audio/minimal/css': () => + import('../../../../packages/skins/src/skins/minimal-live-audio/skin.tsx?style=css&target=html&skin=minimal-live-audio'), + 'html/live-audio/minimal/tailwind': () => + import('../../../../packages/skins/src/skins/minimal-live-audio/skin.tsx?style=tailwind&target=html&skin=minimal-live-audio'), +} satisfies Record Promise>; + +/** The export a compiled skin module carries, such as `DefaultLiveVideoSkin`; the same name on both targets. */ +export function authoredExportName(preset: SkinPreset, skin: Skin): string { + const words = [skin, ...preset.split('-')].map((word) => word.charAt(0).toUpperCase() + word.slice(1)); + + return `${words.join('')}Skin`; +} + +/** + * Tailwind for authored skins: the skins' own entry plus the utilities the compiler recorded. Loaded once, and only for + * a Tailwind skin, so pages that never show one never pull a second Tailwind root. + */ +let tailwind: Promise | undefined; + +function loadAuthoredTailwind(): Promise { + tailwind ??= import('../styles.authored.css'); + + return tailwind; +} + +export async function loadAuthoredSkinModule( + target: 'react' | 'html', + preset: SkinPreset, + skin: Skin, + styling: Styling +): Promise { + const [module] = await Promise.all([ + authoredSkins[`${target}/${preset}/${skin}/${styling}`](), + styling === 'tailwind' ? loadAuthoredTailwind() : undefined, + ]); + + return module; +} diff --git a/apps/sandbox/app/shared/captions.ts b/apps/sandbox/app/shared/captions.ts new file mode 100644 index 0000000000..82055e9686 --- /dev/null +++ b/apps/sandbox/app/shared/captions.ts @@ -0,0 +1,37 @@ +import type { MediaLike } from './media-element'; + +/** How many subtitle tracks the page adds to a video: none, one, or two, which is what fills a captions menu. */ +export const CAPTIONS_MODES = ['none', 'single', 'multiple'] as const; +export type CaptionsMode = (typeof CAPTIONS_MODES)[number]; + +const CAPTIONS_SRC = new URL('./captions.vtt?no-inline', import.meta.url).href; + +const TRACKS = [ + { label: 'English', lang: 'en' }, + { label: 'Spanish', lang: 'es' }, +] as const; + +export function captionTracks(mode: CaptionsMode): readonly { readonly label: string; readonly lang: string }[] { + if (mode === 'none') return []; + + return mode === 'single' ? TRACKS.slice(0, 1) : TRACKS; +} + +/** + * Add the sandbox's subtitle tracks to a media element, replacing the ones a previous render added. A custom media + * element reads its tracks when it upgrades, so markup should receive them while still inert; see `findMediaTag`. + */ +export function applyCaptionTracks(media: MediaLike | Element, mode: CaptionsMode): void { + for (const stale of media.querySelectorAll('track[data-sandbox-captions]')) stale.remove(); + + for (const { label, lang } of captionTracks(mode)) { + const track = document.createElement('track'); + + track.kind = 'subtitles'; + track.label = label; + track.srclang = lang; + track.src = CAPTIONS_SRC; + track.dataset.sandboxCaptions = ''; + media.append(track); + } +} diff --git a/packages/skins/dev/captions.vtt b/apps/sandbox/app/shared/captions.vtt similarity index 100% rename from packages/skins/dev/captions.vtt rename to apps/sandbox/app/shared/captions.vtt diff --git a/apps/sandbox/app/shared/html/authored-skins.ts b/apps/sandbox/app/shared/html/authored-skins.ts new file mode 100644 index 0000000000..7f05533b30 --- /dev/null +++ b/apps/sandbox/app/shared/html/authored-skins.ts @@ -0,0 +1,35 @@ +import type { Skin, Styling } from '@app/types'; + +import { authoredExportName, loadAuthoredSkinModule } from '../authored-skins'; +import { defineTemplateSkin, type SkinTemplate } from './registry-skins'; +import { authoredSkinTag, type SkinPreset } from './skin-tags'; + +/** What a compiled html skin module exports: a render function whose result serialises to markup around a ``. */ +type HtmlSkinRender = (props?: { className?: string }) => { toString(): string }; + +function skinRender(module: object, name: string): HtmlSkinRender { + // SAFETY: a module namespace is a plain object keyed by export name; the value is checked below. + const render = (module as Record)[name]; + if (typeof render !== 'function') throw new Error(`Authored skin module did not export ${name}.`); + + // SAFETY: the html target exports its skin as a function that renders to a string-like value. + return render as HtmlSkinRender; +} + +/** Compiled html output keeps the authored `` for the media and a named `` for the poster. */ +const authoredTemplate = (markup: string): SkinTemplate => ({ + markup, + media: (container) => container.querySelector('slot:not([name])'), + poster: (container) => container.querySelector('slot[name="poster"]'), +}); + +/** Compile an authored html skin, define the element that renders it around the page's media, and return its tag. */ +export async function loadAuthoredHtmlSkinTag(preset: SkinPreset, skin: Skin, styling: Styling): Promise { + const tagName = authoredSkinTag(preset, skin, styling); + if (customElements.get(tagName)) return tagName; + + const module = await loadAuthoredSkinModule('html', preset, skin, styling); + const render = skinRender(module, authoredExportName(preset, skin)); + + return defineTemplateSkin(tagName, authoredTemplate(String(render()))); +} diff --git a/apps/sandbox/app/shared/html/i18n.ts b/apps/sandbox/app/shared/html/i18n.ts index 6efd4dcd3c..0f44804a4d 100644 --- a/apps/sandbox/app/shared/html/i18n.ts +++ b/apps/sandbox/app/shared/html/i18n.ts @@ -1,15 +1,19 @@ import '@videojs/html/i18n'; import { syncDocumentLocale } from '../i18n/document-locale'; import { ensureSandboxLocale, type SandboxLocaleTag } from '../i18n/sandbox-locales'; -import { getInitialLocale, onLocaleChange } from '../sandbox-listener'; +import { getDirection, getInitialLocale, onLocaleChange } from '../sandbox-listener'; let locale: SandboxLocaleTag = getInitialLocale(); let localeApplySeq = 0; syncDocumentLocale(locale); +/** The provider carries the pinned direction as its own `dir`, which it keeps over the one its locale implies. */ export function wrapSandboxHtmlI18n(content: string): string { - return `${content}`; + const direction = getDirection(); + const dir = direction === 'auto' ? '' : ` dir="${direction}"`; + + return `${content}`; } export async function prepareSandboxHtmlLocale(): Promise { diff --git a/apps/sandbox/app/shared/html/tailwind-skins.ts b/apps/sandbox/app/shared/html/registry-skins.ts similarity index 56% rename from apps/sandbox/app/shared/html/tailwind-skins.ts rename to apps/sandbox/app/shared/html/registry-skins.ts index e59d9f6237..562e5b78dc 100644 --- a/apps/sandbox/app/shared/html/tailwind-skins.ts +++ b/apps/sandbox/app/shared/html/registry-skins.ts @@ -1,70 +1,83 @@ import type { Skin } from '@app/types'; import { ContainerElement } from '@videojs/html'; -import { LIVE_AUDIO_TAILWIND_SKIN_TAGS, LIVE_VIDEO_TAILWIND_SKIN_TAGS, TAILWIND_SKIN_TAGS } from './skin-tags'; +import { registrySkinTag, type SkinPreset } from './skin-tags'; -interface TailwindSkinModule { +interface RegistrySkinModule { readonly default: string; } -type SkinLoader = () => Promise; +type SkinLoader = () => Promise; -const videoSkins = { - default: () => +/** The registry's html skins: a template installed as a file beside the module that registers its elements and CSS. */ +const registrySkins = { + 'video/default': () => Promise.all([ import('@app/_generated/html/components/videojs/skins/video/skin.html?raw'), import('@app/_generated/html/components/videojs/skins/video/skin'), ]), - minimal: () => + 'video/minimal': () => Promise.all([ import('@app/_generated/html/components/videojs/skins/video/minimal/skin.html?raw'), import('@app/_generated/html/components/videojs/skins/video/minimal/skin'), ]), -} satisfies Record; - -const liveVideoSkins = { - default: () => + 'live-video/default': () => Promise.all([ import('@app/_generated/html/components/videojs/skins/live-video/skin.html?raw'), import('@app/_generated/html/components/videojs/skins/live-video/skin'), ]), - minimal: () => + 'live-video/minimal': () => Promise.all([ import('@app/_generated/html/components/videojs/skins/live-video/minimal/skin.html?raw'), import('@app/_generated/html/components/videojs/skins/live-video/minimal/skin'), ]), -} satisfies Record; - -const audioSkins = { - default: () => + 'audio/default': () => Promise.all([ import('@app/_generated/html/components/videojs/skins/audio/skin.html?raw'), import('@app/_generated/html/components/videojs/skins/audio/skin'), ]), - minimal: () => + 'audio/minimal': () => Promise.all([ import('@app/_generated/html/components/videojs/skins/audio/minimal/skin.html?raw'), import('@app/_generated/html/components/videojs/skins/audio/minimal/skin'), ]), -} satisfies Record; - -const liveAudioSkins = { - default: () => + 'live-audio/default': () => Promise.all([ import('@app/_generated/html/components/videojs/skins/live-audio/skin.html?raw'), import('@app/_generated/html/components/videojs/skins/live-audio/skin'), ]), - minimal: () => + 'live-audio/minimal': () => Promise.all([ import('@app/_generated/html/components/videojs/skins/live-audio/minimal/skin.html?raw'), import('@app/_generated/html/components/videojs/skins/live-audio/minimal/skin'), ]), -} satisfies Record; +} satisfies Record<`${SkinPreset}/${Skin}`, SkinLoader>; + +/** A skin shipped as markup: where the page's media goes, and where a slotted poster image goes. */ +export interface SkinTemplate { + readonly markup: string; + /** The node the media element replaces. */ + readonly media: (container: HTMLElement) => ChildNode | null; + /** The node a slotted poster replaces, or that is unwrapped to its own children when none is slotted. */ + readonly poster: (container: HTMLElement) => Element | null; +} -function defineTailwindSkin(tagName: string, source: string): string { +/** The registry rewrites the media slot into a comment and renders the poster's fallback image in place. */ +const registryTemplate = (markup: string): SkinTemplate => ({ + markup, + media: findMediaMarker, + poster: (container) => container.querySelector('media-poster img'), +}); + +/** + * Define an element that stamps a skin template around its own children: the media element takes the template's media + * position, an `` child takes the poster's, and the container's classes and attributes move onto the + * host so the page's frame classes still apply. + */ +export function defineTemplateSkin(tagName: string, source: SkinTemplate): string { if (customElements.get(tagName)) return tagName; - class SandboxTailwindSkinElement extends ContainerElement { + class SandboxTemplateSkinElement extends ContainerElement { #rendered = false; override connectedCallback(): void { @@ -79,16 +92,16 @@ function defineTailwindSkin(tagName: string, source: string): string { const template = document.createElement('template'); - template.innerHTML = source; + template.innerHTML = source.markup; const container = template.content.firstElementChild; if (!(container instanceof HTMLElement) || container.localName !== 'media-container') { - throw new Error(`Source-owned skin ${tagName} has no media-container root.`); + throw new Error(`Skin ${tagName} has no media-container root.`); } - const marker = findMediaMarker(container); - if (!marker) throw new Error(`Source-owned skin ${tagName} has no media marker.`); + const marker = source.media(container); + if (!marker) throw new Error(`Skin ${tagName} has no place for the media element.`); const poster = this.querySelector(':scope > [slot="poster"]'); @@ -98,9 +111,13 @@ function defineTailwindSkin(tagName: string, source: string): string { marker.remove(); + const posterTarget = source.poster(container); + if (poster instanceof HTMLImageElement) { poster.removeAttribute('slot'); - container.querySelector('media-poster img')?.replaceWith(poster); + posterTarget?.replaceWith(poster); + } else if (posterTarget instanceof HTMLSlotElement) { + posterTarget.replaceWith(...posterTarget.childNodes); } container.classList.add(...this.classList); @@ -118,7 +135,7 @@ function defineTailwindSkin(tagName: string, source: string): string { } } - customElements.define(tagName, SandboxTailwindSkinElement); + customElements.define(tagName, SandboxTemplateSkinElement); return tagName; } @@ -135,26 +152,12 @@ function findMediaMarker(root: HTMLElement): Comment | null { return null; } -async function loadTailwindSkin(loader: SkinLoader, tagName: string): Promise { +/** Define and return the element that renders a registry-installed html skin around the page's media. */ +export async function loadRegistrySkinTag(preset: SkinPreset, skin: Skin): Promise { + const tagName = registrySkinTag(preset, skin); if (customElements.get(tagName)) return tagName; - const [module] = await loader(); - - return defineTailwindSkin(tagName, module.default); -} - -export function loadSandboxVideoTailwindSkin(skin: Skin): Promise { - return loadTailwindSkin(videoSkins[skin], TAILWIND_SKIN_TAGS[skin].video); -} - -export function loadSandboxAudioTailwindSkin(skin: Skin): Promise { - return loadTailwindSkin(audioSkins[skin], TAILWIND_SKIN_TAGS[skin].audio); -} - -export function loadSandboxLiveVideoTailwindSkin(skin: Skin): Promise { - return loadTailwindSkin(liveVideoSkins[skin], LIVE_VIDEO_TAILWIND_SKIN_TAGS[skin]); -} + const [module] = await registrySkins[`${preset}/${skin}`](); -export function loadSandboxLiveAudioTailwindSkin(skin: Skin): Promise { - return loadTailwindSkin(liveAudioSkins[skin], LIVE_AUDIO_TAILWIND_SKIN_TAGS[skin]); + return defineTemplateSkin(tagName, registryTemplate(module.default)); } diff --git a/apps/sandbox/app/shared/html/sandbox-state.ts b/apps/sandbox/app/shared/html/sandbox-state.ts deleted file mode 100644 index da4e191ebc..0000000000 --- a/apps/sandbox/app/shared/html/sandbox-state.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - getInitialAutoplay, - getInitialLoop, - getInitialMuted, - getInitialPreload, - getInitialSkin, - getInitialSource, - type PreloadValue, -} from '@app/shared/sandbox-listener'; -import type { SourceId } from '@app/shared/sources'; -import type { Skin, Styling } from '@app/types'; - -function getInitialStyling(): Styling { - return new URLSearchParams(location.search).get('styling') === 'tailwind' ? 'tailwind' : 'css'; -} - -export type HtmlSandboxState = { - skin: Skin; - source: SourceId; - styling: Styling; - autoplay: boolean; - muted: boolean; - loop: boolean; - preload: PreloadValue; -}; - -export function createHtmlSandboxState(): HtmlSandboxState { - return { - skin: getInitialSkin(), - source: getInitialSource(), - styling: getInitialStyling(), - autoplay: getInitialAutoplay(), - muted: getInitialMuted(), - loop: getInitialLoop(), - preload: getInitialPreload(), - }; -} - -/** Render the user-controlled media attributes (autoplay/muted/loop/preload) as HTML attributes. */ -export function renderMediaAttrs(state: HtmlSandboxState): string { - return [ - state.autoplay ? 'autoplay' : '', - state.muted ? 'muted' : '', - state.loop ? 'loop' : '', - `preload="${state.preload}"`, - ] - .filter(Boolean) - .join(' '); -} - -export function createLatestLoader() { - let loadVersion = 0; - - return async (load: () => Promise): Promise => { - const version = ++loadVersion; - - try { - const result = await load(); - - return version === loadVersion ? result : undefined; - } catch (error) { - // Swallow load errors to avoid unhandled promise rejections in callers - // that do not await the returned promise. Callers can treat `undefined` - // as a signal that no valid result is available. - console.error('Failed to load latest result', error); - return undefined; - } - }; -} diff --git a/apps/sandbox/app/shared/html/sandbox.ts b/apps/sandbox/app/shared/html/sandbox.ts new file mode 100644 index 0000000000..04433fff91 --- /dev/null +++ b/apps/sandbox/app/shared/html/sandbox.ts @@ -0,0 +1,239 @@ +import type { MuxSource } from '@videojs/media/dom/mux'; +import { escapeHtml } from '@videojs/utils/string'; + +import { applyCaptionTracks } from '../captions'; +import { findMediaTag } from '../media-element'; +import { PLAYER_FRAME_CLASSES } from '../player-frame'; +import { + getDirection, + getInitialPlaybackOverrides, + onDirectionChange, + onSandboxStateChange, + type PlaybackOverrides, + readSandboxState, + type SandboxState, +} from '../sandbox-listener'; +import { installSandboxMirror } from '../sandbox-mirror'; +import { getChapters, getPlaceholderSrc, getPosterSrc, getStoryboardSrc, isLiveSource, SOURCES } from '../sources'; +import { renderChapters } from './chapters'; +import { bindSandboxHtmlLocaleChange, prepareSandboxHtmlLocale, wrapSandboxHtmlI18n } from './i18n'; +import { loadHtmlSkinTag } from './skins'; +import { renderStoryboard } from './storyboard'; + +/** Tag for markup literals. `String.raw`, so a template reads as the HTML it produces. */ +export const html = String.raw; + +/** The player a page mounts, which fixes its element, its skin family, and the frame around them. */ +export type HtmlSandboxPlayer = 'video' | 'audio' | 'background'; + +/** A source assigned as an object, for what a `src` attribute cannot carry: tokens, license servers, engine options. */ +export type HtmlSandboxSource = MuxSource | ({ src: string } & PlaybackOverrides); + +/** What a template's media markup can read: the shell's selections plus what the runtime derived from them. */ +export interface HtmlSandboxContext { + readonly state: Readonly; + /** The live player and skin variants are in use for this render. */ + readonly live: boolean; + /** The selected source's plain URL, or empty when it has none. */ + readonly url: string; + /** ` src="…"` for the media element, or empty when the source has to be assigned as an object after render. */ + readonly src: string; + /** The object to assign to the media element's `source` once rendered, when `src` is empty. */ + readonly source: HtmlSandboxSource | undefined; + /** The attributes the settings menu controls: autoplay, muted, loop, and preload. */ + readonly attrs: string; + /** Chapter tracks for the source, or empty. */ + readonly chapters: string; + /** The storyboard track for the source, or empty. */ + readonly storyboard: string; +} + +export interface HtmlSandboxOptions { + readonly player: HtmlSandboxPlayer; + /** Switch to the live player and skin while the selected source is live. Leave off for media that cannot play one. */ + readonly live?: boolean; + /** + * How the poster reaches the skin. `image` slots the source's poster image after the media. `derived` hands the URL + * to the player and slots a blurred placeholder before the media instead, for media that derives its poster from + * `src`. Neither renders by default. + */ + readonly poster?: 'image' | 'derived'; + /** + * Fold the query-string playback overrides into the initial source. That forces the object form, so the engine is + * built with them rather than reconfigured afterwards. + */ + readonly playbackOverrides?: boolean; + /** The media element and any media components beside it, inside the skin. */ + readonly media: (context: HtmlSandboxContext) => string; + /** Runs once the markup is in the document, for what an attribute cannot carry: assigning `context.source`. */ + readonly attach?: (context: HtmlSandboxContext) => void; +} + +/** Render the user-controlled media attributes (autoplay/muted/loop/preload) as HTML attributes. */ +export function renderMediaAttrs(state: SandboxState): string { + return [ + state.autoplay ? 'autoplay' : '', + state.muted ? 'muted' : '', + state.loop ? 'loop' : '', + `preload="${state.preload}"`, + ] + .filter(Boolean) + .join(' '); +} + +export function createLatestLoader() { + let loadVersion = 0; + + return async (load: () => Promise): Promise => { + const version = ++loadVersion; + + try { + const result = await load(); + + return version === loadVersion ? result : undefined; + } catch (error) { + // Swallow load errors to avoid unhandled promise rejections in callers + // that do not await the returned promise. Callers can treat `undefined` + // as a signal that no valid result is available. + console.error('Failed to load latest result', error); + return undefined; + } + }; +} + +function loadSkinTag(player: HtmlSandboxPlayer, state: SandboxState, live: boolean): Promise { + // The background skin is one element with no skin or styling variants, imported by the template. + if (player === 'background') return Promise.resolve('background-video-skin'); + + return loadHtmlSkinTag({ player, live, skin: state.skin, styling: state.styling, source: state.skins }); +} + +function describeSource(state: SandboxState, playbackOverrides: boolean) { + const { source, url = '' } = SOURCES[state.source]; + const overrides = playbackOverrides ? getInitialPlaybackOverrides() : {}; + const initial = source ?? (Object.keys(overrides).length > 0 ? { src: url } : undefined); + + return { + url, + src: initial ? '' : ` src="${escapeHtml(url)}"`, + source: initial ? { ...initial, ...overrides } : undefined, + }; +} + +function createContext(options: HtmlSandboxOptions, state: SandboxState, live: boolean): HtmlSandboxContext { + const { url, src, source } = describeSource(state, options.playbackOverrides === true); + + return { + state, + live, + url, + src, + source, + attrs: renderMediaAttrs(state), + chapters: renderChapters(getChapters(state.source)), + storyboard: renderStoryboard(getStoryboardSrc(state.source)), + }; +} + +function renderPlayer(options: HtmlSandboxOptions, skinTag: string, context: HtmlSandboxContext): string { + const { player, poster } = options; + const { live, state } = context; + const posterSrc = poster === undefined ? undefined : getPosterSrc(state.source); + const placeholder = poster === 'derived' ? getPlaceholderSrc(state.source) : undefined; + const children = html` + ${placeholder ? `` : ''} + ${options.media(context)} + ${poster === 'image' && posterSrc ? html`Video poster` : ''} + `; + + if (player === 'background') { + return html` + + <${skinTag}>${children} + + `; + } + + if (player === 'audio') { + const playerTag = live ? 'live-audio-player' : 'audio-player'; + + return html` +
+ <${playerTag}> + <${skinTag}>${children} + +
+ `; + } + + const playerTag = live ? 'live-video-player' : 'video-player'; + const posterAttr = poster === 'derived' && posterSrc ? ` poster="${escapeHtml(posterSrc)}"` : ''; + + return html` + <${playerTag}${posterAttr}> + <${skinTag} class="${PLAYER_FRAME_CLASSES.video}">${children} + + `; +} + +function getRoot(): HTMLElement { + const root = document.getElementById('root'); + if (!root) throw new Error('The sandbox page has no #root element.'); + + return root; +} + +/** + * Mount a preview page: read the shell's selections, load the skin they name, render the player around the template's + * media markup, and render again as the shell streams changes. A locale change applies in place through `` + * once the player is up; a direction change renders again, since the provider owns the pinned `dir`. + */ +export function createHtmlSandbox(options: HtmlSandboxOptions): void { + const state = readSandboxState('html'); + const loadLatest = createLatestLoader(); + const root = getRoot(); + + installSandboxMirror(); + + async function render(): Promise { + await prepareSandboxHtmlLocale(); + + const live = options.live === true && isLiveSource(state.source); + const skinTag = await loadLatest(() => loadSkinTag(options.player, state, live)); + if (!skinTag) return; + + const context = createContext(options, state, live); + + const template = document.createElement('template'); + + template.innerHTML = wrapSandboxHtmlI18n(renderPlayer(options, skinTag, context)); + + // Subtitle tracks are the page's to add, so a template never has to spell them out. They go in while the markup is + // still inert: a custom media element reads its tracks when it upgrades, not when children arrive later. + const media = options.player === 'video' ? findMediaTag(template.content) : undefined; + + if (media) applyCaptionTracks(media, state.captions); + + root.replaceChildren(template.content); + options.attach?.(context); + } + + void render(); + + onSandboxStateChange((change) => { + Object.assign(state, change); + void render(); + }); + + // The shell repeats the direction after load, so only an actual change is worth a render. + let direction = getDirection(); + + onDirectionChange((next) => { + if (next === direction) return; + + direction = next; + void render(); + }); + + bindSandboxHtmlLocaleChange(render); +} diff --git a/apps/sandbox/app/shared/html/skin-tags.ts b/apps/sandbox/app/shared/html/skin-tags.ts index 02b0733e43..e6265dc423 100644 --- a/apps/sandbox/app/shared/html/skin-tags.ts +++ b/apps/sandbox/app/shared/html/skin-tags.ts @@ -1,34 +1,26 @@ import type { Skin } from '@app/types'; -type SkinTagMap = Record; +/** The player preset a skin is built for, which is how the skins catalog and the framework packages name them. */ +export type SkinPreset = 'video' | 'audio' | 'live-video' | 'live-audio'; -export const CSS_SKIN_TAGS: SkinTagMap = { - default: { video: 'video-skin', audio: 'audio-skin' }, - minimal: { video: 'video-minimal-skin', audio: 'audio-minimal-skin' }, -}; +export function skinPreset(player: 'video' | 'audio', live: boolean): SkinPreset { + return live ? `live-${player}` : player; +} -export const TAILWIND_SKIN_TAGS: SkinTagMap = { - default: { video: 'video-skin-tailwind', audio: 'audio-skin-tailwind' }, - minimal: { video: 'video-minimal-skin-tailwind', audio: 'audio-minimal-skin-tailwind' }, -}; +/** The custom element the framework package registers for a skin, such as `video-minimal-skin`. */ +export function packageSkinTag(preset: SkinPreset, skin: Skin): string { + return skin === 'minimal' ? `${preset}-minimal-skin` : `${preset}-skin`; +} -/** Custom element tag names for the live HLS video preset (`@videojs/html/live-video` skins). */ -export const LIVE_VIDEO_CSS_SKIN_TAGS: Record = { - default: 'live-video-skin', - minimal: 'live-video-minimal-skin', -}; +/** + * The element the sandbox defines around a registry-installed template. Named the way the skins catalog records the + * registry tag, so the two agree even though the html registry ships the CSS styling. + */ +export function registrySkinTag(preset: SkinPreset, skin: Skin): string { + return `${packageSkinTag(preset, skin)}-tailwind`; +} -export const LIVE_VIDEO_TAILWIND_SKIN_TAGS: Record = { - default: 'live-video-skin-tailwind', - minimal: 'live-video-minimal-skin-tailwind', -}; - -export const LIVE_AUDIO_CSS_SKIN_TAGS: Record = { - default: 'live-audio-skin', - minimal: 'live-audio-minimal-skin', -}; - -export const LIVE_AUDIO_TAILWIND_SKIN_TAGS: Record = { - default: 'live-audio-skin-tailwind', - minimal: 'live-audio-minimal-skin-tailwind', -}; +/** The element the sandbox defines around a compiled authored skin, one per styling since each is its own module. */ +export function authoredSkinTag(preset: SkinPreset, skin: Skin, styling: 'css' | 'tailwind'): string { + return `${packageSkinTag(preset, skin)}-authored-${styling}`; +} diff --git a/apps/sandbox/app/shared/html/skins.ts b/apps/sandbox/app/shared/html/skins.ts index f3d2a610ff..6827b0594a 100644 --- a/apps/sandbox/app/shared/html/skins.ts +++ b/apps/sandbox/app/shared/html/skins.ts @@ -1,91 +1,60 @@ -import type { Skin, Styling } from '@app/types'; +import type { Skin, SkinSource, Styling } from '@app/types'; -import { CSS_SKIN_TAGS, LIVE_AUDIO_CSS_SKIN_TAGS, LIVE_VIDEO_CSS_SKIN_TAGS } from './skin-tags'; +import { packageSkinTag, type SkinPreset, skinPreset } from './skin-tags'; import { loadAudioStylesheets, loadVideoStylesheets } from './stylesheets'; -async function loadVideoCssSkin(skin: Skin): Promise { - if (skin === 'default') { - await import('@videojs/html/video/skin'); - } else { - await import('@videojs/html/video/minimal-skin'); - } - - await loadVideoStylesheets(skin); - - return CSS_SKIN_TAGS[skin].video; -} - -async function loadAudioCssSkin(skin: Skin, live: boolean): Promise { - if (live) { - if (skin === 'default') { - await import('@videojs/html/live-audio/skin'); - } else { - await import('@videojs/html/live-audio/minimal-skin'); - } - } else if (skin === 'default') { - await import('@videojs/html/audio/skin'); - } else { - await import('@videojs/html/audio/minimal-skin'); - } - - await loadAudioStylesheets(skin, live); - - return live ? LIVE_AUDIO_CSS_SKIN_TAGS[skin] : CSS_SKIN_TAGS[skin].audio; +export interface HtmlSkinRequest { + readonly player: 'video' | 'audio'; + readonly live: boolean; + readonly skin: Skin; + readonly styling: Styling; + readonly source: SkinSource; } -async function loadVideoTailwindSkin(skin: Skin): Promise { - const { loadSandboxVideoTailwindSkin } = await import('./tailwind-skins'); +/** The framework package's skin modules, which register the custom element for each preset and skin. */ +const packageSkins = { + 'video/default': () => import('@videojs/html/video/skin'), + 'video/minimal': () => import('@videojs/html/video/minimal-skin'), + 'live-video/default': () => import('@videojs/html/live-video/skin'), + 'live-video/minimal': () => import('@videojs/html/live-video/minimal-skin'), + 'audio/default': () => import('@videojs/html/audio/skin'), + 'audio/minimal': () => import('@videojs/html/audio/minimal-skin'), + 'live-audio/default': () => import('@videojs/html/live-audio/skin'), + 'live-audio/minimal': () => import('@videojs/html/live-audio/minimal-skin'), +} satisfies Record<`${SkinPreset}/${Skin}`, () => Promise>; - return loadSandboxVideoTailwindSkin(skin); -} - -async function loadAudioTailwindSkin(skin: Skin, live: boolean): Promise { - const { loadSandboxAudioTailwindSkin, loadSandboxLiveAudioTailwindSkin } = await import('./tailwind-skins'); +async function loadPackageSkin({ player, live, skin }: HtmlSkinRequest, preset: SkinPreset): Promise { + await packageSkins[`${preset}/${skin}`](); + await (player === 'audio' ? loadAudioStylesheets(skin, live) : loadVideoStylesheets(skin, live)); - return live ? loadSandboxLiveAudioTailwindSkin(skin) : loadSandboxAudioTailwindSkin(skin); + return packageSkinTag(preset, skin); } -async function loadLiveVideoCssSkin(skin: Skin): Promise { - if (skin === 'default') { - await import('@videojs/html/live-video/skin'); - } else { - await import('@videojs/html/live-video/minimal-skin'); - } - - await loadVideoStylesheets(skin, true); +async function loadRegistrySkin({ skin }: HtmlSkinRequest, preset: SkinPreset): Promise { + const { loadRegistrySkinTag } = await import('./registry-skins'); - return LIVE_VIDEO_CSS_SKIN_TAGS[skin]; + return loadRegistrySkinTag(preset, skin); } -async function loadLiveVideoTailwindSkin(skin: Skin): Promise { - const { loadSandboxLiveVideoTailwindSkin } = await import('./tailwind-skins'); +async function loadAuthoredSkin({ skin, styling }: HtmlSkinRequest, preset: SkinPreset): Promise { + const { loadAuthoredHtmlSkinTag } = await import('./authored-skins'); - return loadSandboxLiveVideoTailwindSkin(skin); + return loadAuthoredHtmlSkinTag(preset, skin, styling); } -type VideoSkinOptions = { live?: boolean }; -type AudioSkinOptions = { live?: boolean }; - /** - * Loads and registers the video skin for the given skin / styling combination and returns its custom element tag name. - * Pass `live: true` to swap in the `live-video` skin variant (same feature set, trimmed time UI). + * Loads and registers the skin a page asked for and returns its custom element tag name. The html registry publishes + * one CSS flavour, so its `styling` is not consulted; the packages ship CSS only. */ -export function loadVideoSkinTag( - skin: Skin, - styling: Styling, - { live = false }: VideoSkinOptions = {} -): Promise { - if (live) { - return styling === 'tailwind' ? loadLiveVideoTailwindSkin(skin) : loadLiveVideoCssSkin(skin); +export function loadHtmlSkinTag(request: HtmlSkinRequest): Promise { + const preset = skinPreset(request.player, request.live); + + switch (request.source) { + case 'package': + return loadPackageSkin(request, preset); + case 'registry': + return loadRegistrySkin(request, preset); + case 'authored': + return loadAuthoredSkin(request, preset); } - - return styling === 'tailwind' ? loadVideoTailwindSkin(skin) : loadVideoCssSkin(skin); -} - -export function loadAudioSkinTag( - skin: Skin, - styling: Styling, - { live = false }: AudioSkinOptions = {} -): Promise { - return styling === 'tailwind' ? loadAudioTailwindSkin(skin, live) : loadAudioCssSkin(skin, live); } diff --git a/apps/sandbox/app/shared/html/stylesheets.ts b/apps/sandbox/app/shared/html/stylesheets.ts index 9d61fb8b2f..a26fa0a109 100644 --- a/apps/sandbox/app/shared/html/stylesheets.ts +++ b/apps/sandbox/app/shared/html/stylesheets.ts @@ -20,21 +20,40 @@ const liveAudioStylesheets = { minimal: new URL('@videojs/html/live-audio/minimal-skin.css', import.meta.url).href, } satisfies Record; -function loadStylesheet(id: string, url: string): Promise { - const existing = document.querySelector(`link[rel="stylesheet"][data-sandbox-stylesheet="${id}"]`); +const loading = new Map }>(); - existing?.remove(); +/** + * One stylesheet per slot. A repeat request for the same URL shares the in-flight load, and a new URL replaces the old + * sheet only once it has loaded: renders can overlap, and removing a `` another render still awaits would leave + * that render hanging. + */ +function loadStylesheet(id: string, url: string): Promise { + const current = loading.get(id); + if (current?.href === url) return current.promise; const link = document.createElement('link'); + const promise = new Promise((resolve, reject) => { + link.addEventListener( + 'load', + () => { + for (const stale of document.querySelectorAll(`link[rel="stylesheet"][data-sandbox-stylesheet="${id}"]`)) { + if (stale !== link) stale.remove(); + } - return new Promise((resolve, reject) => { - link.addEventListener('load', () => resolve(), { once: true }); + resolve(); + }, + { once: true } + ); link.addEventListener('error', () => reject(new Error(`Could not load skin stylesheet: ${url}`)), { once: true }); link.dataset.sandboxStylesheet = id; link.rel = 'stylesheet'; link.href = url; document.head.appendChild(link); }); + + loading.set(id, { href: url, promise }); + + return promise; } export function loadVideoStylesheets(skin: Skin, live = false): Promise { diff --git a/apps/sandbox/app/shared/i18n/document-locale.ts b/apps/sandbox/app/shared/i18n/document-locale.ts index 7ceb3b5553..3fb6e2170a 100644 --- a/apps/sandbox/app/shared/i18n/document-locale.ts +++ b/apps/sandbox/app/shared/i18n/document-locale.ts @@ -1,8 +1,19 @@ import { getTextDirection } from '@videojs/utils/i18n'; +let pinnedDirection: 'ltr' | 'rtl' | undefined; + export function syncDocumentLocale(locale: string): void { if (typeof document === 'undefined') return; document.documentElement.lang = locale; - document.documentElement.dir = getTextDirection(locale); + document.documentElement.dir = pinnedDirection ?? getTextDirection(locale); +} + +/** Pin the document's direction regardless of locale, or hand it back to the locale with `auto`. */ +export function setDocumentDirection(direction: 'auto' | 'ltr' | 'rtl'): void { + pinnedDirection = direction === 'auto' ? undefined : direction; + + if (typeof document === 'undefined') return; + + document.documentElement.dir = pinnedDirection ?? getTextDirection(document.documentElement.lang || 'en'); } diff --git a/apps/sandbox/app/shared/media-element.ts b/apps/sandbox/app/shared/media-element.ts new file mode 100644 index 0000000000..bd3ed2fd2b --- /dev/null +++ b/apps/sandbox/app/shared/media-element.ts @@ -0,0 +1,32 @@ +/** A native media element, or a custom element such as `` that speaks its API. */ +export type MediaLike = HTMLMediaElement; + +function isMediaLike(element: Element): element is MediaLike { + return 'currentTime' in element && 'paused' in element && 'play' in element && typeof element.play === 'function'; +} + +/** The page's media element, wherever the skin put it; skin parts are skipped because they never play anything. */ +export function findMediaElement(scope: ParentNode = document): MediaLike | undefined { + for (const element of scope.querySelectorAll('*')) { + if (element.localName.startsWith('media-')) continue; + + if (isMediaLike(element)) return element; + } + + return undefined; +} + +/** + * The media element by tag, for markup that has not been adopted into the document yet: a native element, or a custom + * `*-video` / `*-audio` element the skin's own `media-*` parts never are. + */ +export function findMediaTag(scope: ParentNode): Element | undefined { + for (const element of scope.querySelectorAll('*')) { + const { localName } = element; + if (localName.startsWith('media-')) continue; + + if (localName === 'video' || localName === 'audio' || /-(?:video|audio)$/.test(localName)) return element; + } + + return undefined; +} diff --git a/apps/sandbox/app/shared/player-frame.ts b/apps/sandbox/app/shared/player-frame.ts new file mode 100644 index 0000000000..9bf21bf837 --- /dev/null +++ b/apps/sandbox/app/shared/player-frame.ts @@ -0,0 +1,25 @@ +import type { MediaPlayer } from '@app/media'; + +/** + * The shell's width control, in CSS pixels. The stops are the rem widths the skins' layouts change around, then the + * end. + */ +export const PLAYER_WIDTH = { + min: 240, + max: 1360, + stops: [384, 512, 672, 960, 1360], +} as const; + +/** The width a preview opens at before the control is touched: the video skins' `4xl` cap or the audio skins' `xl`. */ +export function defaultPlayerWidth(player: MediaPlayer): number { + return player === 'audio' ? 576 : 896; +} + +/** + * How a preview frames its player: centred, and capped by the shell's width control through `--sandbox-player-width`, + * with the skin's own cap when a page is opened without one. + */ +export const PLAYER_FRAME_CLASSES = { + video: 'mx-auto aspect-video max-w-[var(--sandbox-player-width,56rem)]', + audio: 'mx-auto w-full max-w-[var(--sandbox-player-width,36rem)]', +} as const; diff --git a/apps/sandbox/app/shared/react/skins.tsx b/apps/sandbox/app/shared/react/skins.tsx index 3fee4081f1..eaa4e53f8b 100644 --- a/apps/sandbox/app/shared/react/skins.tsx +++ b/apps/sandbox/app/shared/react/skins.tsx @@ -1,113 +1,132 @@ -import type { Skin, Styling } from '@app/types'; +import { PLAYER_FRAME_CLASSES } from '@app/shared/player-frame'; +import type { Skin, SkinSource, Styling } from '@app/types'; import type { AudioSkinProps } from '@videojs/react/audio'; import type { VideoSkinProps } from '@videojs/react/video'; -import type { ComponentType } from 'react'; -import { createElement, useEffect, useState } from 'react'; - -async function loadTailwindVideoSkin(skin: Skin, live: boolean): Promise> { - if (live) { - if (skin === 'default') { - const { DefaultLiveVideoSkin } = await import('@app/_generated/components/videojs/skins/live-video/skin'); - - return DefaultLiveVideoSkin; - } - - const { MinimalLiveVideoSkin } = await import('@app/_generated/components/videojs/skins/live-video/minimal/skin'); - - return MinimalLiveVideoSkin; - } - - if (skin === 'default') { - const { DefaultVideoSkin } = await import('@app/_generated/components/videojs/skins/video/skin'); - - return DefaultVideoSkin; - } - - const { MinimalVideoSkin } = await import('@app/_generated/components/videojs/skins/video/minimal/skin'); - - return MinimalVideoSkin; +import type { ComponentType, RefObject } from 'react'; +import { createElement, useEffect, useRef, useState } from 'react'; + +import { applyCaptionTracks, type CaptionsMode } from '../captions'; +import { findMediaElement } from '../media-element'; +import { useDirection } from './use-direction'; +import { useSandbox } from './use-sandbox'; + +type SkinPreset = 'video' | 'audio' | 'live-video' | 'live-audio'; +type SkinKey = `${SkinPreset}/${Skin}`; +type Loader = () => Promise; + +interface SkinRequest { + readonly preset: SkinPreset; + readonly skin: Skin; + readonly styling: Styling; + readonly source: SkinSource; } -async function loadVideoSkinComponent( - skin: Skin, - styling: Styling, - live: boolean -): Promise> { - if (styling === 'tailwind') return loadTailwindVideoSkin(skin, live); - - if (live) { - const module = await import('@videojs/react/live-video'); - - if (skin === 'default') { - await import('@videojs/react/live-video/skin.css'); - return module.LiveVideoSkin; - } - - await import('@videojs/react/live-video/minimal-skin.css'); - return module.MinimalLiveVideoSkin; - } - - const module = await import('@videojs/react/video'); - - if (skin === 'default') { - await import('@videojs/react/video/skin.css'); - return module.VideoSkin; - } - - await import('@videojs/react/video/minimal-skin.css'); - return module.MinimalVideoSkin; +/** One module per preset in `@videojs/react`, exporting both skins, with a stylesheet per skin beside it. */ +const packageSkins: Record< + SkinPreset, + { module: Loader; styles: Record; components: Record } +> = { + video: { + module: () => import('@videojs/react/video'), + styles: { + default: () => import('@videojs/react/video/skin.css'), + minimal: () => import('@videojs/react/video/minimal-skin.css'), + }, + components: { default: 'VideoSkin', minimal: 'MinimalVideoSkin' }, + }, + 'live-video': { + module: () => import('@videojs/react/live-video'), + styles: { + default: () => import('@videojs/react/live-video/skin.css'), + minimal: () => import('@videojs/react/live-video/minimal-skin.css'), + }, + components: { default: 'LiveVideoSkin', minimal: 'MinimalLiveVideoSkin' }, + }, + audio: { + module: () => import('@videojs/react/audio'), + styles: { + default: () => import('@videojs/react/audio/skin.css'), + minimal: () => import('@videojs/react/audio/minimal-skin.css'), + }, + components: { default: 'AudioSkin', minimal: 'MinimalAudioSkin' }, + }, + 'live-audio': { + module: () => import('@videojs/react/live-audio'), + styles: { + default: () => import('@videojs/react/live-audio/skin.css'), + minimal: () => import('@videojs/react/live-audio/minimal-skin.css'), + }, + components: { default: 'LiveAudioSkin', minimal: 'MinimalLiveAudioSkin' }, + }, +}; + +/** + * The registry installs: the Tailwind catalog under `@`, the CSS catalog under `@css`. Both use the catalog's export + * names. + */ +const registrySkins: Record> = { + tailwind: { + 'video/default': () => import('@app/_generated/components/videojs/skins/video/skin'), + 'video/minimal': () => import('@app/_generated/components/videojs/skins/video/minimal/skin'), + 'live-video/default': () => import('@app/_generated/components/videojs/skins/live-video/skin'), + 'live-video/minimal': () => import('@app/_generated/components/videojs/skins/live-video/minimal/skin'), + 'audio/default': () => import('@app/_generated/components/videojs/skins/audio/skin'), + 'audio/minimal': () => import('@app/_generated/components/videojs/skins/audio/minimal/skin'), + 'live-audio/default': () => import('@app/_generated/components/videojs/skins/live-audio/skin'), + 'live-audio/minimal': () => import('@app/_generated/components/videojs/skins/live-audio/minimal/skin'), + }, + css: { + 'video/default': () => import('@css/components/videojs/skins/video/skin'), + 'video/minimal': () => import('@css/components/videojs/skins/video/minimal/skin'), + 'live-video/default': () => import('@css/components/videojs/skins/live-video/skin'), + 'live-video/minimal': () => import('@css/components/videojs/skins/live-video/minimal/skin'), + 'audio/default': () => import('@css/components/videojs/skins/audio/skin'), + 'audio/minimal': () => import('@css/components/videojs/skins/audio/minimal/skin'), + 'live-audio/default': () => import('@css/components/videojs/skins/live-audio/skin'), + 'live-audio/minimal': () => import('@css/components/videojs/skins/live-audio/minimal/skin'), + }, +}; + +const registryComponents: Record = { + 'video/default': 'DefaultVideoSkin', + 'video/minimal': 'MinimalVideoSkin', + 'live-video/default': 'DefaultLiveVideoSkin', + 'live-video/minimal': 'MinimalLiveVideoSkin', + 'audio/default': 'DefaultAudioSkin', + 'audio/minimal': 'MinimalAudioSkin', + 'live-audio/default': 'DefaultLiveAudioSkin', + 'live-audio/minimal': 'MinimalLiveAudioSkin', +}; + +function pickComponent(module: object, name: string, key: string): ComponentType { + // SAFETY: a module namespace is a plain object keyed by export name; the value is checked below. + const component = (module as Record)[name]; + if (typeof component !== 'function') throw new Error(`Skin module ${key} did not export ${name}.`); + + // SAFETY: a skin module exports its skin as a React component under the catalogued name. + return component as ComponentType; } -async function loadAudioSkinComponent( - skin: Skin, - styling: Styling, - live: boolean -): Promise> { - if (styling === 'tailwind') { - if (live) { - if (skin === 'default') { - const { DefaultLiveAudioSkin } = await import('@app/_generated/components/videojs/skins/live-audio/skin'); - - return DefaultLiveAudioSkin; - } +async function loadSkinComponent(request: SkinRequest): Promise> { + const { preset, skin, styling, source } = request; + const key: SkinKey = `${preset}/${skin}`; - const { MinimalLiveAudioSkin } = await import('@app/_generated/components/videojs/skins/live-audio/minimal/skin'); + switch (source) { + case 'package': { + const entry = packageSkins[preset]; + const [module] = await Promise.all([entry.module(), entry.styles[skin]()]); - return MinimalLiveAudioSkin; + return pickComponent(module, entry.components[skin], key); } + case 'registry': + return pickComponent(await registrySkins[styling][key](), registryComponents[key], key); + case 'authored': { + const { authoredExportName, loadAuthoredSkinModule } = await import('@app/shared/authored-skins'); + const module = await loadAuthoredSkinModule('react', preset, skin, styling); - if (skin === 'default') { - const { DefaultAudioSkin } = await import('@app/_generated/components/videojs/skins/audio/skin'); - - return DefaultAudioSkin; + return pickComponent(module, authoredExportName(preset, skin), key); } - - const { MinimalAudioSkin } = await import('@app/_generated/components/videojs/skins/audio/minimal/skin'); - - return MinimalAudioSkin; - } - - if (live) { - const module = await import('@videojs/react/live-audio'); - - if (skin === 'default') { - await import('@videojs/react/live-audio/skin.css'); - return module.LiveAudioSkin; - } - - await import('@videojs/react/live-audio/minimal-skin.css'); - return module.MinimalLiveAudioSkin; - } - - const module = await import('@videojs/react/audio'); - - if (skin === 'default') { - await import('@videojs/react/audio/skin.css'); - return module.AudioSkin; } - - await import('@videojs/react/audio/minimal-skin.css'); - return module.MinimalAudioSkin; } function useLoadedComponent( @@ -125,10 +144,11 @@ function useLoadedComponent( setComponent(() => resolved); }) - .catch(() => { + .catch((error) => { if (!active) return; - // Intentionally ignore load errors to avoid unhandled promise rejections. - // The component will remain null, and callers can handle absence as needed. + + // The component stays null; the page shows nothing rather than a half-styled player. + console.error('Failed to load skin', error); }); return () => { @@ -141,21 +161,68 @@ function useLoadedComponent( return component; } -type VideoSkinComponentProps = { skin: Skin; styling: Styling; live?: boolean } & VideoSkinProps; +/** Subtitle tracks are the page's to add, so a template never has to spell them out. */ +function useCaptionTracks(root: RefObject, captions: CaptionsMode, deps: readonly unknown[]) { + useEffect(() => { + const media = root.current ? findMediaElement(root.current) : undefined; + + if (media) applyCaptionTracks(media, captions); + // the media element changes with the source and the skin, which the caller lists + // oxlint-disable-next-line react/exhaustive-deps + }, [captions, ...deps]); +} + +/** The skin derives `dir` from its locale unless given one, so a pinned direction has to arrive as a prop. */ +function useDirectionProps(): { dir?: 'ltr' | 'rtl' } { + const direction = useDirection(); + + return direction === 'auto' ? {} : { dir: direction }; +} + +type VideoSkinComponentProps = { live?: boolean } & VideoSkinProps; + +/** + * Loads the video skin the shell selected, from the source it selected, framed the way every sandbox page frames a + * player unless a `className` says otherwise. When `live` is true, the `live-video` skin variant is used instead. + */ +export function VideoSkinComponent({ + live = false, + className = PLAYER_FRAME_CLASSES.video, + ...props +}: VideoSkinComponentProps) { + const { skin, styling, skins, source, captions } = useSandbox(); + const preset: SkinPreset = live ? 'live-video' : 'video'; + const Component = useLoadedComponent( + () => loadSkinComponent({ preset, skin, styling, source: skins }), + [preset, skin, styling, skins] + ); + const directionProps = useDirectionProps(); + const rootRef = useRef(null); + + useCaptionTracks(rootRef, captions, [Component, source]); -/** Loads the video skin for the given skin/styling. When `live` is true, the `live-video` skin variant is used instead. */ -export function VideoSkinComponent({ skin, styling, live = false, ...props }: VideoSkinComponentProps) { - const Component = useLoadedComponent(() => loadVideoSkinComponent(skin, styling, live), [skin, styling, live]); if (!Component) return null; - return createElement(Component, props); + // SAFETY: React 19 hands `ref` to a function component as a prop, and every skin spreads its props onto the container. + return createElement(Component, { ...props, ...directionProps, className, ref: rootRef } as VideoSkinProps); } -type AudioSkinComponentProps = { skin: Skin; styling: Styling; live?: boolean } & AudioSkinProps; +type AudioSkinComponentProps = { live?: boolean } & AudioSkinProps; + +export function AudioSkinComponent({ + live = false, + className = PLAYER_FRAME_CLASSES.audio, + ...props +}: AudioSkinComponentProps) { + const { skin, styling, skins } = useSandbox(); + const preset: SkinPreset = live ? 'live-audio' : 'audio'; + const Component = useLoadedComponent( + () => loadSkinComponent({ preset, skin, styling, source: skins }), + [preset, skin, styling, skins] + ); + const directionProps = useDirectionProps(); -export function AudioSkinComponent({ skin, styling, live = false, ...props }: AudioSkinComponentProps) { - const Component = useLoadedComponent(() => loadAudioSkinComponent(skin, styling, live), [skin, styling, live]); if (!Component) return null; - return createElement(Component, props); + return createElement(Component, { ...props, ...directionProps, className }); } diff --git a/apps/sandbox/app/shared/react/use-autoplay.ts b/apps/sandbox/app/shared/react/use-autoplay.ts deleted file mode 100644 index ec8163ac0d..0000000000 --- a/apps/sandbox/app/shared/react/use-autoplay.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { getInitialAutoplay, onAutoplayChange } from '@app/shared/sandbox-listener'; -import { useEffect, useState } from 'react'; - -export function useAutoplay(): boolean { - const [autoplay, setAutoplay] = useState(getInitialAutoplay); - - useEffect(() => onAutoplayChange(setAutoplay), []); - return autoplay; -} diff --git a/apps/sandbox/app/shared/react/use-direction.ts b/apps/sandbox/app/shared/react/use-direction.ts new file mode 100644 index 0000000000..24e4647236 --- /dev/null +++ b/apps/sandbox/app/shared/react/use-direction.ts @@ -0,0 +1,11 @@ +import { getDirection, onDirectionChange, type TextDirection } from '@app/shared/sandbox-listener'; +import { useEffect, useState } from 'react'; + +/** The shell's pinned text direction, or `auto` to let the player follow its locale. */ +export function useDirection(): TextDirection { + const [direction, setDirection] = useState(getDirection); + + useEffect(() => onDirectionChange(setDirection), []); + + return direction; +} diff --git a/apps/sandbox/app/shared/react/use-loop.ts b/apps/sandbox/app/shared/react/use-loop.ts deleted file mode 100644 index 8dcf511034..0000000000 --- a/apps/sandbox/app/shared/react/use-loop.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { getInitialLoop, onLoopChange } from '@app/shared/sandbox-listener'; -import { useEffect, useState } from 'react'; - -export function useLoop(): boolean { - const [loop, setLoop] = useState(getInitialLoop); - - useEffect(() => onLoopChange(setLoop), []); - return loop; -} diff --git a/apps/sandbox/app/shared/react/use-muted.ts b/apps/sandbox/app/shared/react/use-muted.ts deleted file mode 100644 index 43902d2b4e..0000000000 --- a/apps/sandbox/app/shared/react/use-muted.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { getInitialMuted, onMutedChange } from '@app/shared/sandbox-listener'; -import { useEffect, useState } from 'react'; - -export function useMuted(): boolean { - const [muted, setMuted] = useState(getInitialMuted); - - useEffect(() => onMutedChange(setMuted), []); - return muted; -} diff --git a/apps/sandbox/app/shared/react/use-placeholder.ts b/apps/sandbox/app/shared/react/use-placeholder.ts deleted file mode 100644 index cbc53ec097..0000000000 --- a/apps/sandbox/app/shared/react/use-placeholder.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useMemo } from 'react'; - -import { getPlaceholderSrc } from '../sources'; -import { useSource } from './use-source'; - -export function usePlaceholder() { - const source = useSource(); - - return useMemo(() => getPlaceholderSrc(source), [source]); -} diff --git a/apps/sandbox/app/shared/react/use-poster.ts b/apps/sandbox/app/shared/react/use-poster.ts deleted file mode 100644 index 6bd5a8ec7a..0000000000 --- a/apps/sandbox/app/shared/react/use-poster.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useMemo } from 'react'; - -import { getPosterSrc } from '../sources'; -import { useSource } from './use-source'; - -export function usePoster() { - const source = useSource(); - - return useMemo(() => getPosterSrc(source), [source]); -} diff --git a/apps/sandbox/app/shared/react/use-preload.ts b/apps/sandbox/app/shared/react/use-preload.ts deleted file mode 100644 index ebcccd819e..0000000000 --- a/apps/sandbox/app/shared/react/use-preload.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { getInitialPreload, onPreloadChange, type PreloadValue } from '@app/shared/sandbox-listener'; -import { useEffect, useState } from 'react'; - -export function usePreload(): PreloadValue { - const [preload, setPreload] = useState(getInitialPreload); - - useEffect(() => onPreloadChange(setPreload), []); - return preload; -} diff --git a/apps/sandbox/app/shared/react/use-sandbox.ts b/apps/sandbox/app/shared/react/use-sandbox.ts new file mode 100644 index 0000000000..b6d6c59c9b --- /dev/null +++ b/apps/sandbox/app/shared/react/use-sandbox.ts @@ -0,0 +1,33 @@ +import { + onSandboxStateChange, + type PreloadValue, + readSandboxState, + type SandboxState, +} from '@app/shared/sandbox-listener'; +import { useEffect, useState } from 'react'; + +import { installSandboxMirror } from './../sandbox-mirror'; + +/** The attributes the settings menu controls, as props for a media component. */ +export interface SandboxMediaProps { + autoPlay: boolean; + muted: boolean; + loop: boolean; + preload: PreloadValue; +} + +export interface Sandbox extends SandboxState { + readonly mediaProps: SandboxMediaProps; +} + +/** The shell's selections for this page, kept current as it streams changes after load. */ +export function useSandbox(): Sandbox { + const [state, setState] = useState(() => readSandboxState('react')); + + useEffect(() => onSandboxStateChange((change) => setState((current) => ({ ...current, ...change }))), []); + useEffect(() => installSandboxMirror(), []); + + const { autoplay, muted, loop, preload } = state; + + return { ...state, mediaProps: { autoPlay: autoplay, muted, loop, preload } }; +} diff --git a/apps/sandbox/app/shared/react/use-skin.ts b/apps/sandbox/app/shared/react/use-skin.ts deleted file mode 100644 index e00c8e422f..0000000000 --- a/apps/sandbox/app/shared/react/use-skin.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { getInitialSkin, onSkinChange } from '@app/shared/sandbox-listener'; -import type { Skin } from '@app/types'; -import { useEffect, useState } from 'react'; - -export function useSkin(): Skin { - const [skin, setSkin] = useState(getInitialSkin); - - useEffect(() => onSkinChange(setSkin), []); - return skin; -} diff --git a/apps/sandbox/app/shared/react/use-source.ts b/apps/sandbox/app/shared/react/use-source.ts deleted file mode 100644 index 32fa51666f..0000000000 --- a/apps/sandbox/app/shared/react/use-source.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { getInitialSource, onSourceChange } from '@app/shared/sandbox-listener'; -import type { SourceId } from '@app/shared/sources'; -import { useEffect, useState } from 'react'; - -export function useSource(): SourceId { - const [source, setSource] = useState(getInitialSource); - - useEffect(() => onSourceChange(setSource), []); - return source; -} diff --git a/apps/sandbox/app/shared/react/use-storyboard.ts b/apps/sandbox/app/shared/react/use-storyboard.ts deleted file mode 100644 index 868d7f3e58..0000000000 --- a/apps/sandbox/app/shared/react/use-storyboard.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useMemo } from 'react'; - -import { getStoryboardSrc } from '../sources'; -import { useSource } from './use-source'; - -export function useStoryboard() { - const source = useSource(); - - return useMemo(() => getStoryboardSrc(source), [source]); -} diff --git a/apps/sandbox/app/shared/sandbox-listener.ts b/apps/sandbox/app/shared/sandbox-listener.ts index eaba68b0f5..7213a04318 100644 --- a/apps/sandbox/app/shared/sandbox-listener.ts +++ b/apps/sandbox/app/shared/sandbox-listener.ts @@ -1,9 +1,13 @@ -import { SKINS } from '@app/constants'; +import { SKIN_SOURCES, SKINS, STYLINGS } from '@app/constants'; import { DEFAULT_SANDBOX_LOCALE, SANDBOX_LOCALE_TAGS, type SandboxLocaleTag } from '@app/shared/i18n/locale-meta'; -import type { Skin } from '@app/types'; +import type { Platform, Skin, SkinSource, Styling } from '@app/types'; import type { MediaResolution } from '@videojs/media'; +import { isBoolean, isNumber, isString } from '@videojs/utils/predicate'; -import { SOURCES, type SourceId } from './sources'; +import { CAPTIONS_MODES, type CaptionsMode } from './captions'; +import { setDocumentDirection } from './i18n/document-locale'; +import { defaultSkinSource } from './skin-sources'; +import { DEFAULT_SOURCE, SOURCES, type SourceId } from './sources'; export const PRELOAD_VALUES = ['none', 'metadata', 'auto'] as const; export type PreloadValue = (typeof PRELOAD_VALUES)[number]; @@ -15,123 +19,115 @@ const RESOLUTION_PATTERN = /^\d+p$/; export const PREFER_PLAYBACK_VALUES = ['mse', 'native'] as const; export type PreferPlaybackValue = (typeof PREFER_PLAYBACK_VALUES)[number]; -const params = new URLSearchParams(window.location.search); +/** `auto` follows the operating system. */ +export const COLOR_SCHEMES = ['auto', 'light', 'dark'] as const; +export type ColorScheme = (typeof COLOR_SCHEMES)[number]; + +/** `auto` follows the locale. */ +export const TEXT_DIRECTIONS = ['auto', 'ltr', 'rtl'] as const; +export type TextDirection = (typeof TEXT_DIRECTIONS)[number]; -function readSkin(): Skin { - const skin = params.get('skin'); +const params = new URLSearchParams(window.location.search); - return skin && SKINS.includes(skin as Skin) ? (skin as Skin) : 'default'; +/** + * The selections a preview page renders from. The shell writes them into the page URL and, once the page has loaded, + * streams each change as a `-change` message. Styling is the exception: changing it remounts the page. + */ +export interface SandboxState { + skin: Skin; + source: SourceId; + styling: Styling; + /** Where the skin comes from. Changing it remounts the page, like styling. */ + skins: SkinSource; + autoplay: boolean; + muted: boolean; + loop: boolean; + preload: PreloadValue; + /** Subtitle tracks the page adds to a video. */ + captions: CaptionsMode; } -function readSource(): SourceId { - const source = params.get('source'); +type StreamedKey = Exclude; - return source && source in SOURCES ? (source as SourceId) : 'hls-1'; +function isOneOf(values: readonly T[], value: unknown): value is T { + // SAFETY: the tuple is widened to strings only for the lookup; the guard narrows the value back to `T`. + return isString(value) && (values as readonly string[]).includes(value); } -function readBoolean(name: string): boolean { - return params.get(name) === '1'; +function parseSkin(value: unknown): Skin | undefined { + return isOneOf(SKINS, value) ? value : undefined; } -function readPreload(): PreloadValue { - const value = params.get('preload'); - - return PRELOAD_VALUES.includes(value as PreloadValue) ? (value as PreloadValue) : DEFAULT_PRELOAD; +function parseStyling(value: unknown): Styling | undefined { + return isOneOf(STYLINGS, value) ? value : undefined; } -function readResolution(name: string): MediaResolution | undefined { - const value = params.get(name); - if (!value || !RESOLUTION_PATTERN.test(value) || Number.parseInt(value, 10) <= 0) return undefined; - - return value as MediaResolution; +function parseSkinSource(value: unknown): SkinSource | undefined { + return isOneOf(SKIN_SOURCES, value) ? value : undefined; } -/** Absent unless named, so the source default is what runs otherwise. */ -function readOptionalBoolean(name: string): boolean | undefined { - const value = params.get(name); - if (value === null) return undefined; - - return value !== '0' && value !== 'false'; +function parseSource(value: unknown): SourceId | undefined { + // SAFETY: `in` checked the string against the source map's keys. + return isString(value) && value in SOURCES ? (value as SourceId) : undefined; } -function readPreferPlayback(): PreferPlaybackValue | undefined { - const value = params.get('preferPlayback'); - - return PREFER_PLAYBACK_VALUES.includes(value as PreferPlaybackValue) ? (value as PreferPlaybackValue) : undefined; +export function parseFlag(value: unknown): boolean | undefined { + return isBoolean(value) ? value : undefined; } -let currentSkin = readSkin(); -let currentSource = readSource(); -let currentAutoplay = readBoolean('autoplay'); -let currentMuted = readBoolean('muted'); -let currentLoop = readBoolean('loop'); -let currentPreload = readPreload(); -let currentLocale = readLocale(); - -const initialMaxAutoResolution = readResolution('maxAutoResolution'); -const initialMinAutoResolution = readResolution('minAutoResolution'); -const initialCapRenditionToPlayerSize = readOptionalBoolean('capRenditionToPlayerSize'); -const initialPreferPlayback = readPreferPlayback(); - -function applyAccentColor(value: string) { - if (value) { - document.documentElement.style.setProperty('--media-accent-color', value); - } else { - document.documentElement.style.removeProperty('--media-accent-color'); - } +function parsePreload(value: unknown): PreloadValue | undefined { + return isOneOf(PRELOAD_VALUES, value) ? value : undefined; } -applyAccentColor(params.get('accent')?.trim() ?? ''); - -window.addEventListener('message', (event) => { - if (event.data?.type !== 'accent-color-change' || typeof event.data.accentColor !== 'string') return; - - applyAccentColor(event.data.accentColor.trim()); -}); +function parseCaptions(value: unknown): CaptionsMode | undefined { + return isOneOf(CAPTIONS_MODES, value) ? value : undefined; +} -function readLocale(): SandboxLocaleTag { - const value = params.get('locale'); +function parseLocale(value: unknown): SandboxLocaleTag | undefined { + return isOneOf(SANDBOX_LOCALE_TAGS, value) ? value : undefined; +} - return SANDBOX_LOCALE_TAGS.includes(value as SandboxLocaleTag) ? (value as SandboxLocaleTag) : DEFAULT_SANDBOX_LOCALE; +/** A query flag is `1` or absent, where the streamed value is a boolean. */ +export function readFlag(name: string): boolean { + return params.get(name) === '1'; } /** - * Playback options read once from the query string and folded into the _initial_ `source`, so the engine is built with - * them instead of having them switched in afterwards. Every key is absent unless named, leaving the default sandbox - * behavior untouched. - * - * - `?maxAutoResolution=720p` caps automatic rendition selection. - * - `?capRenditionToPlayerSize=0` stops the element's size from capping it. - * - `?minAutoResolution=270p` lowers the floor on that size cap, whose default is `720p` — low enough here to leave a - * small player uncapped. - * - `?preferPlayback=native` forces the browser's own HLS. + * The selections the page URL names, with the shell's defaults for the rest. The platform decides where skins come from + * when the URL does not say, since only React has a Tailwind registry install. */ -export function getInitialPlaybackOverrides(): { - maxAutoResolution?: MediaResolution; - capRenditionToPlayerSize?: boolean; - minAutoResolution?: MediaResolution; - preferPlayback?: PreferPlaybackValue; -} { +export function readSandboxState(platform: Platform): SandboxState { + const styling = parseStyling(params.get('styling')) ?? 'css'; + return { - ...(initialMaxAutoResolution && { maxAutoResolution: initialMaxAutoResolution }), - ...(initialCapRenditionToPlayerSize !== undefined && { - capRenditionToPlayerSize: initialCapRenditionToPlayerSize, - }), - ...(initialMinAutoResolution && { minAutoResolution: initialMinAutoResolution }), - ...(initialPreferPlayback && { preferPlayback: initialPreferPlayback }), + skin: parseSkin(params.get('skin')) ?? 'default', + source: parseSource(params.get('source')) ?? DEFAULT_SOURCE, + styling, + skins: parseSkinSource(params.get('skins')) ?? defaultSkinSource(platform, styling), + autoplay: readFlag('autoplay'), + muted: readFlag('muted'), + loop: readFlag('loop'), + preload: parsePreload(params.get('preload')) ?? DEFAULT_PRELOAD, + captions: parseCaptions(params.get('captions')) ?? 'none', }; } -export function getInitialSkin(): Skin { - return currentSkin; -} - -export function onSkinChange(callback: (skin: Skin) => void): () => void { +/** + * Listen for one message type from the shell. The payload has to pass `parse`; a message that fails it is dropped, so a + * malformed post cannot put the page into a state the shell would never send. + */ +export function subscribeMessage( + type: string, + parse: (data: Record) => T | undefined, + callback: (value: T) => void +): () => void { const handler = (event: MessageEvent) => { - if (event.data?.type !== 'skin-change' || !SKINS.includes(event.data.skin)) return; + if (event.data?.type !== type) return; - currentSkin = event.data.skin; - callback(currentSkin); + const value = parse(event.data); + if (value === undefined) return; + + callback(value); }; window.addEventListener('message', handler); @@ -141,116 +137,200 @@ export function onSkinChange(callback: (skin: Skin) => void): () => void { }; } -export function getInitialSource(): SourceId { - return currentSource; +/** Listen for the shell's `-change` messages, whose payload field is named `name`. */ +export function subscribe( + name: string, + parse: (value: unknown) => T | undefined, + callback: (value: T) => void +): () => void { + return subscribeMessage(`${name}-change`, (data) => parse(data[name]), callback); } -export function onSourceChange(callback: (source: SourceId) => void): () => void { - const handler = (event: MessageEvent) => { - if (event.data?.type !== 'source-change' || !(event.data.source in SOURCES)) return; - - currentSource = event.data.source; - callback(currentSource); - }; +const streamedKeys: readonly StreamedKey[] = ['skin', 'source', 'autoplay', 'muted', 'loop', 'preload', 'captions']; + +const streamed: { [K in StreamedKey]: (value: unknown) => SandboxState[K] | undefined } = { + skin: parseSkin, + source: parseSource, + autoplay: parseFlag, + muted: parseFlag, + loop: parseFlag, + preload: parsePreload, + captions: parseCaptions, +}; + +function streamKey(key: K, callback: (change: Partial) => void): () => void { + return subscribe(key, streamed[key], (value) => { + const change: Partial = {}; + + change[key] = value; + callback(change); + }); +} - window.addEventListener('message', handler); +/** Calls back with each selection the shell streams in after load, as a partial state to merge into the last one. */ +export function onSandboxStateChange(callback: (change: Partial) => void): () => void { + const unsubscribes = streamedKeys.map((key) => streamKey(key, callback)); return () => { - window.removeEventListener('message', handler); + for (const unsubscribe of unsubscribes) unsubscribe(); }; } -export function getInitialAutoplay(): boolean { - return currentAutoplay; +let currentLocale = parseLocale(params.get('locale')) ?? DEFAULT_SANDBOX_LOCALE; + +export function getInitialLocale(): SandboxLocaleTag { + return currentLocale; } -export function onAutoplayChange(callback: (autoplay: boolean) => void): () => void { - const handler = (event: MessageEvent) => { - if (event.data?.type !== 'autoplay-change' || typeof event.data.autoplay !== 'boolean') return; +export function onLocaleChange(callback: (locale: SandboxLocaleTag) => void): () => void { + return subscribe('locale', parseLocale, (locale) => { + currentLocale = locale; + callback(locale); + }); +} - currentAutoplay = event.data.autoplay; - callback(currentAutoplay); - }; +function parseAccent(value: unknown): string | undefined { + return isString(value) ? value.trim() : undefined; +} - window.addEventListener('message', handler); +/** A width arrives as a query string or as a number from the shell; either way it is CSS pixels. */ +function parseWidth(value: unknown): number | undefined { + const width = isString(value) ? Number.parseInt(value, 10) : isNumber(value) ? value : Number.NaN; - return () => { - window.removeEventListener('message', handler); - }; + return Number.isFinite(width) && width > 0 ? width : undefined; } -export function getInitialMuted(): boolean { - return currentMuted; +function parseScheme(value: unknown): ColorScheme | undefined { + return isOneOf(COLOR_SCHEMES, value) ? value : undefined; } -export function onMutedChange(callback: (muted: boolean) => void): () => void { - const handler = (event: MessageEvent) => { - if (event.data?.type !== 'muted-change' || typeof event.data.muted !== 'boolean') return; +function parseDirection(value: unknown): TextDirection | undefined { + return isOneOf(TEXT_DIRECTIONS, value) ? value : undefined; +} - currentMuted = event.data.muted; - callback(currentMuted); - }; +/** + * A preference the page applies to its document rather than renders from: read once from the query string, then kept + * current from the shell's `-change` messages. Absent means the page's own default. + */ +function preference( + name: string, + parse: (value: unknown) => T | undefined, + apply: (value: T | undefined) => void +): void { + apply(parse(params.get(name))); + subscribe(name, parse, apply); +} - window.addEventListener('message', handler); +const { style } = document.documentElement; - return () => { - window.removeEventListener('message', handler); - }; -} +preference('accent', parseAccent, (accent) => { + if (accent) style.setProperty('--media-accent-color', accent); + else style.removeProperty('--media-accent-color'); +}); -export function getInitialLoop(): boolean { - return currentLoop; -} +// The templates cap the player at `--sandbox-player-width`, falling back to the skin's own width when unset. +preference('width', parseWidth, (width) => { + if (width) style.setProperty('--sandbox-player-width', `${width}px`); + else style.removeProperty('--sandbox-player-width'); +}); -export function onLoopChange(callback: (loop: boolean) => void): () => void { - const handler = (event: MessageEvent) => { - if (event.data?.type !== 'loop-change' || typeof event.data.loop !== 'boolean') return; +// `color-scheme` and the `dark:` variant both key off this attribute; see `styles.css`. +preference('scheme', parseScheme, (scheme) => { + if (scheme && scheme !== 'auto') document.documentElement.dataset.colorScheme = scheme; + else delete document.documentElement.dataset.colorScheme; +}); - currentLoop = event.data.loop; - callback(currentLoop); +// The page flips with the document, and the player takes the same value as its own `dir` because it would otherwise +// derive one from its locale; see `html/i18n.ts` and `react/skins.tsx`. +let currentDirection: TextDirection = 'auto'; + +/** + * Relay what goes wrong in a frame to the shell, which lists it in the copied report. Uncaught errors, unhandled + * rejections, and `console.error` all count; the console still receives each of them. + */ +function relayErrors(): void { + if (window.parent === window) return; + + const relay = (message: string) => window.parent.postMessage({ type: 'sandbox-error', message }, '*'); + const describe = (value: unknown) => (value instanceof Error ? `${value.name}: ${value.message}` : String(value)); + const consoleError = console.error.bind(console); + + window.addEventListener('error', (event) => relay(event.message || describe(event.error))); + window.addEventListener('unhandledrejection', (event) => relay(`Unhandled rejection: ${describe(event.reason)}`)); + console.error = (...args: unknown[]) => { + relay(args.map(describe).join(' ')); + consoleError(...args); }; +} - window.addEventListener('message', handler); +relayErrors(); - return () => { - window.removeEventListener('message', handler); - }; +preference('dir', parseDirection, (direction) => { + currentDirection = direction ?? 'auto'; + setDocumentDirection(currentDirection); +}); + +export function getDirection(): TextDirection { + return currentDirection; } -export function getInitialPreload(): PreloadValue { - return currentPreload; +export function onDirectionChange(callback: (direction: TextDirection) => void): () => void { + return subscribe('dir', parseDirection, callback); } -export function onPreloadChange(callback: (preload: PreloadValue) => void): () => void { - const handler = (event: MessageEvent) => { - if (event.data?.type !== 'preload-change' || !PRELOAD_VALUES.includes(event.data.preload)) return; +function readResolution(name: string): MediaResolution | undefined { + const value = params.get(name); + if (!value || !RESOLUTION_PATTERN.test(value) || Number.parseInt(value, 10) <= 0) return undefined; - currentPreload = event.data.preload; - callback(currentPreload); - }; + // SAFETY: the pattern guarantees the `{height}p` shape the resolution type names. + return value as MediaResolution; +} - window.addEventListener('message', handler); +/** Absent unless named, so the source default is what runs otherwise. */ +function readOptionalBoolean(name: string): boolean | undefined { + const value = params.get(name); + if (value === null) return undefined; - return () => { - window.removeEventListener('message', handler); - }; + return value !== '0' && value !== 'false'; } -export function getInitialLocale(): SandboxLocaleTag { - return currentLocale; -} +function readPreferPlayback(): PreferPlaybackValue | undefined { + const value = params.get('preferPlayback'); -export function onLocaleChange(callback: (locale: SandboxLocaleTag) => void): () => void { - const handler = (event: MessageEvent) => { - if (event.data?.type !== 'locale-change' || !SANDBOX_LOCALE_TAGS.includes(event.data.locale)) return; + return isOneOf(PREFER_PLAYBACK_VALUES, value) ? value : undefined; +} - currentLocale = event.data.locale; - callback(currentLocale); - }; +const initialMaxAutoResolution = readResolution('maxAutoResolution'); +const initialMinAutoResolution = readResolution('minAutoResolution'); +const initialCapRenditionToPlayerSize = readOptionalBoolean('capRenditionToPlayerSize'); +const initialPreferPlayback = readPreferPlayback(); - window.addEventListener('message', handler); +/** Engine options folded into the initial source, so the engine is built with them instead of reconfigured. */ +export interface PlaybackOverrides { + maxAutoResolution?: MediaResolution | undefined; + capRenditionToPlayerSize?: boolean | undefined; + minAutoResolution?: MediaResolution | undefined; + preferPlayback?: PreferPlaybackValue | undefined; +} - return () => { - window.removeEventListener('message', handler); +/** + * Playback options read once from the query string and folded into the _initial_ `source`, so the engine is built with + * them instead of having them switched in afterwards. Every key is absent unless named, leaving the default sandbox + * behavior untouched. + * + * - `?maxAutoResolution=720p` caps automatic rendition selection. + * - `?capRenditionToPlayerSize=0` stops the element's size from capping it. + * - `?minAutoResolution=270p` lowers the floor on that size cap, whose default is `720p` — low enough here to leave a + * small player uncapped. + * - `?preferPlayback=native` forces the browser's own HLS. + */ +export function getInitialPlaybackOverrides(): PlaybackOverrides { + return { + ...(initialMaxAutoResolution && { maxAutoResolution: initialMaxAutoResolution }), + ...(initialCapRenditionToPlayerSize !== undefined && { + capRenditionToPlayerSize: initialCapRenditionToPlayerSize, + }), + ...(initialMinAutoResolution && { minAutoResolution: initialMinAutoResolution }), + ...(initialPreferPlayback && { preferPlayback: initialPreferPlayback }), }; } diff --git a/apps/sandbox/app/shared/sandbox-mirror.ts b/apps/sandbox/app/shared/sandbox-mirror.ts new file mode 100644 index 0000000000..7c9836dd5d --- /dev/null +++ b/apps/sandbox/app/shared/sandbox-mirror.ts @@ -0,0 +1,159 @@ +import { isBoolean, isNumber, isString } from '@videojs/utils/predicate'; + +import { findMediaElement, type MediaLike } from './media-element'; +import { parseFlag, readFlag, subscribe, subscribeMessage } from './sandbox-listener'; + +/** The playback state one compare panel reports and the other applies. Moves state, not coordinates. */ +export interface MirroredState { + readonly paused: boolean; + readonly currentTime: number; + readonly volume: number; + readonly muted: boolean; + readonly playbackRate: number; + readonly textTracks: readonly MirroredTextTrack[]; +} + +interface MirroredTextTrack { + readonly kind: string; + readonly label: string; + readonly language: string; + readonly mode: TextTrackMode; +} + +const EVENTS = ['play', 'pause', 'seeked', 'volumechange', 'ratechange'] as const; +const TEXT_TRACK_MODES: readonly TextTrackMode[] = ['disabled', 'hidden', 'showing']; +/** Seeks closer than this are the drift of two players running, not a seek worth mirroring. */ +const SEEK_TOLERANCE = 0.5; + +let enabled = readFlag('mirror'); +let media: MediaLike | undefined; +let unbind: (() => void) | undefined; +let applying = false; +let lastReported = ''; + +function snapshot(element: MediaLike): MirroredState { + return { + paused: element.paused, + currentTime: element.currentTime, + volume: element.volume, + muted: element.muted, + playbackRate: element.playbackRate, + textTracks: [...element.textTracks].map(({ kind, label, language, mode }) => ({ kind, label, language, mode })), + }; +} + +function report(): void { + if (!enabled || applying || !media || window.parent === window) return; + + const state = snapshot(media); + const serialized = JSON.stringify(state); + if (serialized === lastReported) return; + + lastReported = serialized; + window.parent.postMessage({ type: 'sandbox-mirror', state }, '*'); +} + +/** Apply a sibling's state, touching only what differs so the resulting events do not echo back as changes. */ +function apply(state: MirroredState): void { + if (!enabled || !media) return; + + applying = true; + + try { + if (media.muted !== state.muted) media.muted = state.muted; + + if (Math.abs(media.volume - state.volume) > 0.001) media.volume = state.volume; + + if (media.playbackRate !== state.playbackRate) media.playbackRate = state.playbackRate; + + if (Math.abs(media.currentTime - state.currentTime) > SEEK_TOLERANCE) media.currentTime = state.currentTime; + + for (const track of media.textTracks) { + const mirrored = state.textTracks.find( + (candidate) => + candidate.kind === track.kind && candidate.label === track.label && candidate.language === track.language + ); + + if (mirrored && track.mode !== mirrored.mode) track.mode = mirrored.mode; + } + + if (state.paused && !media.paused) media.pause(); + // A play the browser refuses without a gesture in this frame stays paused; the report from here says so. + else if (!state.paused && media.paused) media.play().catch(() => undefined); + } finally { + applying = false; + } + + lastReported = JSON.stringify(snapshot(media)); +} + +function bind(element: MediaLike | undefined): void { + if (element === media) return; + + unbind?.(); + media = element; + unbind = undefined; + lastReported = ''; + + if (!element) return; + + for (const event of EVENTS) element.addEventListener(event, report); + + element.textTracks.addEventListener('change', report); + unbind = () => { + for (const event of EVENTS) element.removeEventListener(event, report); + + element.textTracks.removeEventListener('change', report); + }; + + // The sibling should not wait for the next media event to learn where this player already is. + report(); +} + +function parseTextTrack(value: unknown): MirroredTextTrack | undefined { + if (typeof value !== 'object' || value === null) return undefined; + + const { kind, label, language, mode } = value as Record; + if (!isString(kind) || !isString(label) || !isString(language) || !isString(mode)) return undefined; + + // SAFETY: the mode was matched against the three text track modes. + return TEXT_TRACK_MODES.includes(mode as TextTrackMode) + ? { kind, label, language, mode: mode as TextTrackMode } + : undefined; +} + +function parseState(data: Record): MirroredState | undefined { + const state = data.state; + if (typeof state !== 'object' || state === null) return undefined; + + const { paused, currentTime, volume, muted, playbackRate, textTracks } = state as Record; + if (!isBoolean(paused) || !isNumber(currentTime) || !isNumber(volume) || !isBoolean(muted)) return undefined; + + if (!isNumber(playbackRate) || !Array.isArray(textTracks)) return undefined; + + const tracks = textTracks.map(parseTextTrack); + if (tracks.some((track) => track === undefined)) return undefined; + + // SAFETY: every track parsed, as checked above. + return { paused, currentTime, volume, muted, playbackRate, textTracks: tracks as MirroredTextTrack[] }; +} + +/** + * Mirror playback between compare panels. Pages re-render their player as the shell streams changes, so the media + * element is re-found whenever the document changes; the shell relays each report to the sibling frames. + */ +export function installSandboxMirror(): void { + if (window.parent === window) return; + + const observer = new MutationObserver(() => bind(findMediaElement())); + + observer.observe(document.documentElement, { childList: true, subtree: true }); + bind(findMediaElement()); + + subscribe('mirror', parseFlag, (value) => { + enabled = value; + lastReported = ''; + report(); + }); + subscribeMessage('mirror-apply', parseState, apply); +} diff --git a/apps/sandbox/app/shared/skin-sources.ts b/apps/sandbox/app/shared/skin-sources.ts new file mode 100644 index 0000000000..0f5876c376 --- /dev/null +++ b/apps/sandbox/app/shared/skin-sources.ts @@ -0,0 +1,69 @@ +import type { Platform, SkinSource, Styling } from '@app/types'; + +/** + * Authored skins compile only where `packages/skins` is checked out; a StackBlitz preview has the published packages + * only. + */ +export const WORKSPACE_SKINS: boolean = __WORKSPACE_SKINS__; + +/** + * Registry skins exist where setup could install them: always in the workspace, elsewhere only if the hosted registry + * answered. + */ +export const REGISTRY_SKINS: boolean = __REGISTRY_SKINS__; + +/** + * The stylings a skin source offers on a platform. The framework packages ship CSS. The registry publishes CSS for both + * platforms and Tailwind for React only, so the html registry install is a CSS skin. The authored sources compile to + * either. The CDN bundles are the packages' CSS skins. + */ +export function skinStylings(platform: Platform, source: SkinSource): readonly Styling[] { + if (platform === 'cdn') return ['css']; + + switch (source) { + case 'package': + return ['css']; + case 'registry': + return platform === 'react' ? ['css', 'tailwind'] : ['css']; + case 'authored': + return ['css', 'tailwind']; + } +} + +/** Whether a source can be loaded at all on a platform, regardless of styling. */ +export function skinSourceAvailable(source: SkinSource, platform: Platform): boolean { + if (platform === 'cdn') return source === 'package'; + + switch (source) { + case 'package': + return true; + case 'registry': + return REGISTRY_SKINS; + case 'authored': + return WORKSPACE_SKINS; + } +} + +/** + * Where skins come from when nothing was asked for: the framework packages for CSS, and for Tailwind the first source + * that both exists here and publishes it: the registry on React, the authored sources on html, the only place an html + * Tailwind skin exists. With neither available, the packages' CSS skin loads rather than modules that are not there. + */ +export function defaultSkinSource(platform: Platform, styling: Styling): SkinSource { + if (styling === 'css') return 'package'; + + const preferred: readonly SkinSource[] = platform === 'html' ? ['authored', 'registry'] : ['registry', 'authored']; + + return ( + preferred.find( + (source) => skinSourceAvailable(source, platform) && skinStylings(platform, source).includes(styling) + ) ?? 'package' + ); +} + +/** Whether any loadable source offers Tailwind on the platform. */ +export function tailwindSkinAvailable(platform: Platform): boolean { + return (['package', 'registry', 'authored'] as const).some( + (source) => skinSourceAvailable(source, platform) && skinStylings(platform, source).includes('tailwind') + ); +} diff --git a/apps/sandbox/app/shared/sources.ts b/apps/sandbox/app/shared/sources.ts index 79d697dc37..b6ae4477d3 100644 --- a/apps/sandbox/app/shared/sources.ts +++ b/apps/sandbox/app/shared/sources.ts @@ -327,6 +327,13 @@ const SOURCE_MAP = { url: 'https://dash.akamaized.net/envivio/EnvivioDash3/manifest.mpd', type: 'dash', }, + // A file that does not exist, so the player's error dialog can be looked at + // without waiting for a network to fail. + error: { + label: 'Missing file (error dialog)', + url: '/missing-video-that-does-not-exist.mp4', + type: 'mp4', + }, // Empty src — exercises source teardown with nothing re-attaching, and the // engine's fresh-but-attached "no source" state. `src` forwards to the host // property rather than being mirrored onto the inner native element, so this diff --git a/apps/sandbox/app/shared/tsconfig.json b/apps/sandbox/app/shared/tsconfig.json deleted file mode 100644 index b503da8c2b..0000000000 --- a/apps/sandbox/app/shared/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../../../tsconfig.base.json", - "compilerOptions": { - "declarationDir": "../../types/shared", - "lib": ["ES2022", "DOM", "DOM.Iterable"] - }, - "include": ["mux.ts", "sources.ts"], - "references": [{ "path": "../../../../packages/media" }] -} diff --git a/apps/sandbox/app/shell/app.tsx b/apps/sandbox/app/shell/app.tsx index 8b8175bbe2..57217b8ff7 100644 --- a/apps/sandbox/app/shell/app.tsx +++ b/apps/sandbox/app/shell/app.tsx @@ -1,129 +1,222 @@ -import { EMBED_PRESETS, PLATFORMS, PRESETS, STYLINGS } from '@app/constants'; +import { + COMPARE_AXES, + COMPARE_LAYOUTS, + compareAvailable, + type CompareLayout, + type CompareMode, + comparePanels, + resolveSkinSource, + type SkinSelection, + summarizeSelection, +} from '@app/compare'; +import { PLATFORMS, SKIN_SOURCES, STYLINGS } from '@app/constants'; +import { COMPARE_LABELS } from '@app/labels'; +import { hasTailwindSkin, isMediaId, MEDIA, type MediaId, mediaSources } from '@app/media'; +import { CAPTIONS_MODES, type CaptionsMode } from '@app/shared/captions'; import { DEFAULT_SANDBOX_LOCALE, SANDBOX_LOCALE_TAGS, type SandboxLocaleTag } from '@app/shared/i18n/locale-meta'; -import { DEFAULT_PRELOAD, PRELOAD_VALUES, type PreloadValue } from '@app/shared/sandbox-listener'; -import type { SourceId } from '@app/shared/sources'; +import { defaultPlayerWidth, PLAYER_WIDTH } from '@app/shared/player-frame'; import { - DASH_SOURCE_IDS, - DEFAULT_BACKGROUND_SOURCE, - DEFAULT_DASH_SOURCE, - DEFAULT_SOURCE, - HLS_SOURCE_IDS, - isDrmSource, - isMuxSource, - MUX_SOURCE_IDS, - MUX_SPF_SOURCE_IDS, - NON_DASH_SOURCE_IDS, - SHAKA_SOURCE_IDS, - SOURCE_IDS, - SOURCES, - SPF_HLS_SOURCE_IDS, -} from '@app/shared/sources'; -import type { Platform, Preset, Styling } from '@app/types'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -import { Navbar } from './navbar'; -import { Preview } from './preview'; - -function getPagePath(platform: Platform, preset: Preset): string { - if (platform === 'cdn') return '/cdn/'; - - return `/${platform}-${preset}/`; + COLOR_SCHEMES, + type ColorScheme, + DEFAULT_PRELOAD, + PRELOAD_VALUES, + type PreloadValue, + TEXT_DIRECTIONS, + type TextDirection, +} from '@app/shared/sandbox-listener'; +import { skinSourceAvailable, skinStylings, tailwindSkinAvailable } from '@app/shared/skin-sources'; +import { DEFAULT_SOURCE, SOURCES, type SourceId } from '@app/shared/sources'; +import type { Platform, SkinSource, Styling } from '@app/types'; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; + +import { Navbar, SkinControls } from './navbar'; +import { OptionsPanel } from './options-panel'; +import { type FrameParams, Preview } from './preview'; +import { describeError, MAX_ERRORS, type RelayedError, usePreferences } from './report'; + +/** `preset` named this parameter before the skins' player presets took the word, so older links still resolve. */ +function readMedia(params: URLSearchParams): MediaId { + const value = params.get('media') ?? params.get('preset'); + + return isMediaId(value) ? value : 'video'; } -/** - * The SPF background presets default to their own source rather than the global one, which is MPEG-TS and so is a - * failure case for that engine rather than a demo of it. Only when nothing was asked for — an explicit `?source=` still - * wins, so a shared link reaches the source it names. - */ -function isSpfBackgroundPreset(preset: Preset): boolean { - return preset === 'hls-background-video' || preset === 'mux-background-video'; +function readOption( + values: readonly T[], + value: string | null, + fallback: Fallback +): T | Fallback { + // SAFETY: the lookup narrows the query value to the option it matched. + return value !== null && (values as readonly string[]).includes(value) ? (value as T) : fallback; +} + +/** An explicit skin source the platform can load. Absent means the default for the styling. */ +function readSkins(value: string | null, platform: Platform): SkinSource | undefined { + const source = readOption(SKIN_SOURCES, value, undefined); + + return source !== undefined && skinSourceAvailable(source, platform) ? source : undefined; +} + +/** An explicit width, clamped to the control's range. Absent means the media's own default. */ +function readWidth(value: string | null): number | undefined { + const width = Number.parseInt(value ?? '', 10); + + return Number.isFinite(width) ? Math.min(PLAYER_WIDTH.max, Math.max(PLAYER_WIDTH.min, width)) : undefined; } function readParams() { const params = new URLSearchParams(location.search); - const preload = params.get('preload'); - const preset = (params.get('preset') ?? 'video') as Preset; + const media = readMedia(params); + const platform = readOption(PLATFORMS, params.get('platform'), 'html'); return { - platform: (params.get('platform') ?? 'html') as Platform, - styling: (params.get('styling') ?? 'css') as Styling, - preset, + platform, + styling: readOption(STYLINGS, params.get('styling'), 'css'), + skins: readSkins(params.get('skins'), platform), + media, skin: (params.get('skin') ?? 'default') as 'default' | 'minimal', - source: (params.get('source') ?? - (isSpfBackgroundPreset(preset) ? DEFAULT_BACKGROUND_SOURCE : DEFAULT_SOURCE)) as SourceId, + // An explicit `?source=` wins over where the media lands on entry, so a shared link reaches the source it names. + source: (params.get('source') ?? MEDIA[media].entrySource ?? DEFAULT_SOURCE) as SourceId, autoplay: params.get('autoplay') === '1', muted: params.get('muted') === '1', loop: params.get('loop') === '1', - preload: PRELOAD_VALUES.includes(preload as PreloadValue) ? (preload as PreloadValue) : DEFAULT_PRELOAD, + preload: readOption(PRELOAD_VALUES, params.get('preload'), DEFAULT_PRELOAD), + captions: readOption(CAPTIONS_MODES, params.get('captions'), 'none'), accentColor: params.get('accent')?.trim() ?? '', - locale: (() => { - const value = params.get('locale'); - - return SANDBOX_LOCALE_TAGS.includes(value as SandboxLocaleTag) - ? (value as SandboxLocaleTag) - : DEFAULT_SANDBOX_LOCALE; - })(), + locale: readOption(SANDBOX_LOCALE_TAGS, params.get('locale'), DEFAULT_SANDBOX_LOCALE), + width: readWidth(params.get('width')), + scheme: readOption(COLOR_SCHEMES, params.get('scheme'), 'auto'), + direction: readOption(TEXT_DIRECTIONS, params.get('dir'), 'auto'), + compare: readOption(COMPARE_AXES, params.get('compare'), 'off'), + layout: readOption(COMPARE_LAYOUTS, params.get('layout'), 'auto'), + mirror: params.get('mirror') === '1', }; } +/** Whether the options panel is open outlives the page: it is chrome, not part of the selection the URL carries. */ +const OPTIONS_STORAGE_KEY = 'sandbox:options'; + +function readOptionsOpen(): boolean { + try { + return localStorage.getItem(OPTIONS_STORAGE_KEY) === 'open'; + } catch { + return false; + } +} + +function storeOptionsOpen(open: boolean): void { + try { + localStorage.setItem(OPTIONS_STORAGE_KEY, open ? 'open' : 'closed'); + } catch { + // Storage can be unavailable; the panel then opens closed next time. + } +} + +/** The preferences a frame applies to its document rather than renders from, repeated to it once it has loaded. */ +function postPreferences(target: Window, params: FrameParams): void { + target.postMessage({ type: 'accent-change', accent: params.accentColor }, '*'); + target.postMessage({ type: 'width-change', width: params.width }, '*'); + target.postMessage({ type: 'scheme-change', scheme: params.scheme }, '*'); + target.postMessage({ type: 'dir-change', dir: params.direction }, '*'); + target.postMessage({ type: 'mirror-change', mirror: params.mirror }, '*'); +} + export function App() { const initial = useMemo(readParams, []); const [platform, setPlatform] = useState(initial.platform); const [styling, setStyling] = useState(initial.styling); - const [preset, setPreset] = useState(initial.preset); + const [media, setMedia] = useState(initial.media); + const [skins, setSkins] = useState(initial.skins); const [skin, setSkin] = useState(initial.skin); const [source, setSource] = useState(initial.source); const [autoplay, setAutoplay] = useState(initial.autoplay); const [muted, setMuted] = useState(initial.muted); const [loop, setLoop] = useState(initial.loop); const [preload, setPreload] = useState(initial.preload); + const [captions, setCaptions] = useState(initial.captions); const [accentColor, setAccentColor] = useState(initial.accentColor); const [locale, setLocale] = useState(initial.locale); + const [width, setWidth] = useState(initial.width); + const [scheme, setScheme] = useState(initial.scheme); + const [direction, setDirection] = useState(initial.direction); + const [compare, setCompare] = useState(initial.compare); + const [layout, setLayout] = useState(initial.layout); + const [mirror, setMirror] = useState(initial.mirror); + const [errors, setErrors] = useState([]); + const [optionsOpen, setOptionsOpen] = useState(readOptionsOpen); + const optionsId = useId(); + const preferences = usePreferences(); + + const descriptor = MEDIA[media]; + const availableSources = mediaSources(media, platform); + const tailwindAvailable = hasTailwindSkin(media, platform) && tailwindSkinAvailable(platform); + // Until the control is touched, the preview opens at the width its skin would have taken on its own. + const playerWidth = width ?? defaultPlayerWidth(descriptor.player); + const resizable = descriptor.player !== 'background'; + + // The frames: one for the selection, or two differing on the compared axis. The first panel is what the navbar + // reports as the resolved skin source. + const selection: SkinSelection = useMemo( + () => ({ platform, styling, skins, skin, media }), + [platform, styling, skins, skin, media] + ); + const panels = useMemo(() => comparePanels(selection, compare), [selection, compare]); + const skinSource = panels[0]?.skins ?? 'package'; + // The stylings the selection itself can show. A styling comparison puts CSS in the first panel, so constraining by + // that panel's source would send a Tailwind selection back to CSS. + const skinStylingsAvailable = skinStylings(platform, resolveSkinSource(platform, styling, skins)); + const compareOptions = useMemo( + () => [ + { value: 'off' as const, label: COMPARE_LABELS.off, disabled: false }, + ...COMPARE_AXES.map((axis) => ({ + value: axis, + label: COMPARE_LABELS[axis], + disabled: !compareAvailable(axis, selection), + })), + ], + [selection] + ); - const iframeRef = useRef(null); - const previousPreviewState = useRef({ skin, source, autoplay, muted, loop, preload, accentColor }); - - const pagePath = getPagePath(platform, preset); - - // `MuxVideo` is the only preset that turns a Mux DRM token into license URLs; - // the HLS presets take license servers through `source.drm`, whichever path - // they play. The CDN sandbox builds elements from attributes alone, so neither - // reaches it. - const structuredSource = platform !== 'cdn'; - const hlsPreset = preset === 'hlsjs-video' || preset === 'native-hls-video'; - const muxPreset = preset === 'mux-video' || preset === 'mux-audio'; - const muxSpfPreset = preset === 'mux-video-spf' || preset === 'mux-audio-spf'; - const spfHlsPreset = preset === 'hls-video' || preset === 'hls-audio'; - // The SPF-backed background presets take the same HLS sources the plain HLS - // presets do — `` is the one that stays fixed, since it hands a - // progressive MP4 to the browser rather than streaming a manifest. - const spfBackgroundPreset = isSpfBackgroundPreset(preset); - // No background preset has a Tailwind skin or a skin choice. - const backgroundPreset = preset === 'background-video' || spfBackgroundPreset; - const embedPreset = (EMBED_PRESETS as readonly Preset[]).includes(preset); - const availableSources = - preset === 'audio' - ? SOURCE_IDS - : preset === 'dash-video' - ? DASH_SOURCE_IDS - : preset === 'shaka-video' - ? SHAKA_SOURCE_IDS - : structuredSource && muxPreset - ? MUX_SOURCE_IDS - : structuredSource && hlsPreset - ? HLS_SOURCE_IDS - : structuredSource && muxSpfPreset - ? MUX_SPF_SOURCE_IDS - : spfHlsPreset || muxSpfPreset || spfBackgroundPreset - ? SPF_HLS_SOURCE_IDS - : NON_DASH_SOURCE_IDS; + const frames = useRef(new Map()); + const previousPreviewState = useRef({ + skin, + source, + autoplay, + muted, + loop, + preload, + captions, + accentColor, + playerWidth, + scheme, + direction, + mirroring: false, + }); + + // Mirroring only means something between two panels. + const mirroring = mirror && compare !== 'off'; + const frameParams: FrameParams = { + media, + source, + autoplay, + muted, + loop, + preload, + captions, + locale, + accentColor, + width: playerWidth, + scheme, + direction, + mirror: mirroring, + }; // Keep the URL in sync with all state. useEffect(() => { const params = new URLSearchParams({ platform, styling, - preset, + media, skin, source, autoplay: autoplay ? '1' : '0', @@ -131,139 +224,313 @@ export function App() { loop: loop ? '1' : '0', preload, locale, + scheme, + dir: direction, }); + if (captions !== 'none') params.set('captions', captions); + if (accentColor) params.set('accent', accentColor); + if (width !== undefined) params.set('width', String(width)); + + if (skins !== undefined) params.set('skins', skins); + + if (compare !== 'off') { + params.set('compare', compare); + + if (layout !== 'auto') params.set('layout', layout); + + if (mirror) params.set('mirror', '1'); + } + history.replaceState(null, '', `/?${params}`); - }, [platform, styling, preset, skin, source, autoplay, muted, loop, preload, accentColor, locale]); + }, [ + platform, + styling, + skins, + media, + skin, + source, + autoplay, + muted, + loop, + preload, + captions, + accentColor, + locale, + width, + scheme, + direction, + compare, + layout, + mirror, + ]); + + // The shell follows the scheme too, so its chrome and the preview agree; see `styles.css` for the `dark:` variant. + useEffect(() => { + if (scheme === 'auto') delete document.documentElement.dataset.colorScheme; + else document.documentElement.dataset.colorScheme = scheme; + }, [scheme]); // Initial state is already present in the iframe URL. Stream only subsequent changes so HTML previews do not race // several identical async renders during startup. Locale changes are URL-owned by Preview because CDN must reload. useEffect(() => { const previous = previousPreviewState.current; - const target = iframeRef.current?.contentWindow; + const targets = [...frames.current.values()] + .map((frame) => frame.contentWindow) + .filter((window) => window !== null); + const post = (message: Record) => { + for (const target of targets) target.postMessage(message, '*'); + }; - if (previous.skin !== skin) target?.postMessage({ type: 'skin-change', skin }, '*'); + if (previous.skin !== skin) post({ type: 'skin-change', skin }); - if (previous.source !== source) target?.postMessage({ type: 'source-change', source }, '*'); + if (previous.source !== source) post({ type: 'source-change', source }); - if (previous.autoplay !== autoplay) target?.postMessage({ type: 'autoplay-change', autoplay }, '*'); + if (previous.autoplay !== autoplay) post({ type: 'autoplay-change', autoplay }); - if (previous.muted !== muted) target?.postMessage({ type: 'muted-change', muted }, '*'); + if (previous.muted !== muted) post({ type: 'muted-change', muted }); - if (previous.loop !== loop) target?.postMessage({ type: 'loop-change', loop }, '*'); + if (previous.loop !== loop) post({ type: 'loop-change', loop }); - if (previous.preload !== preload) target?.postMessage({ type: 'preload-change', preload }, '*'); + if (previous.preload !== preload) post({ type: 'preload-change', preload }); - if (previous.accentColor !== accentColor) { - target?.postMessage({ type: 'accent-color-change', accentColor }, '*'); - } + if (previous.captions !== captions) post({ type: 'captions-change', captions }); + + if (previous.accentColor !== accentColor) post({ type: 'accent-change', accent: accentColor }); + + if (previous.playerWidth !== playerWidth) post({ type: 'width-change', width: playerWidth }); + + if (previous.scheme !== scheme) post({ type: 'scheme-change', scheme }); + + if (previous.direction !== direction) post({ type: 'dir-change', dir: direction }); + + if (previous.mirroring !== mirroring) post({ type: 'mirror-change', mirror: mirroring }); + + previousPreviewState.current = { + skin, + source, + autoplay, + muted, + loop, + preload, + captions, + accentColor, + playerWidth, + scheme, + direction, + mirroring, + }; + }, [skin, source, autoplay, muted, loop, preload, captions, accentColor, playerWidth, scheme, direction, mirroring]); + + // Keep the last few errors the frames relay, tagged with the panel they came from, for the report. + useEffect(() => { + const collect = (event: MessageEvent) => { + if (event.data?.type !== 'sandbox-error') return; + + const panel = [...frames.current.entries()].find(([, frame]) => frame.contentWindow === event.source)?.[0]; + const entry: RelayedError = { + panel: panel ?? 'frame', + time: new Date().toISOString().slice(11, 19), + message: describeError(event.data.message), + }; + + setErrors((current) => [...current, entry].slice(-MAX_ERRORS)); + }; - previousPreviewState.current = { skin, source, autoplay, muted, loop, preload, accentColor }; - }, [skin, source, autoplay, muted, loop, preload, accentColor]); + window.addEventListener('message', collect); - // Constrain source to DASH when switching to dash-video + return () => { + window.removeEventListener('message', collect); + }; + }, []); + + // Relay one panel's playback state to the others; the frames apply only what differs, so nothing echoes. useEffect(() => { - if (preset === 'dash-video' && SOURCES[source].type !== 'dash') { - setSource(DEFAULT_DASH_SOURCE); - } - }, [preset, source]); + if (!mirroring) return; + + const relay = (event: MessageEvent) => { + if (event.data?.type !== 'sandbox-mirror') return; + + for (const frame of frames.current.values()) { + const target = frame.contentWindow; - // Constrain source away from DASH for presets that cannot play it. Shaka is - // not one of them — it plays DASH and HLS from the same element. + if (target && target !== event.source) + target.postMessage({ type: 'mirror-apply', state: event.data.state }, '*'); + } + }; + + window.addEventListener('message', relay); + + return () => { + window.removeEventListener('message', relay); + }; + }, [mirroring]); + + // Constrain the source to what the media offers on this platform. useEffect(() => { - if (preset !== 'audio' && preset !== 'dash-video' && preset !== 'shaka-video' && SOURCES[source].type === 'dash') { - setSource(DEFAULT_SOURCE); - } - }, [preset, source]); + if (!availableSources.includes(source)) setSource(descriptor.fallbackSource ?? DEFAULT_SOURCE); + }, [availableSources, descriptor.fallbackSource, source]); - // Land the SPF background presets on their own default when *switched into*, - // rather than inheriting whatever the previous preset was showing — - // `readParams` covers the first-mount half. Keyed on entry, so a source picked - // afterwards sticks. - const previousPreset = useRef(preset); + // Land on the media's own source when *switched into*, rather than inheriting whatever the previous media was + // showing — `readParams` covers the first-mount half. Keyed on entry, so a source picked afterwards sticks. Declared + // after the constraint so the landing wins when both fire in one pass. + const previousMedia = useRef(media); useEffect(() => { - const entered = spfBackgroundPreset && previousPreset.current !== preset; + const entered = previousMedia.current !== media; - previousPreset.current = preset; + previousMedia.current = media; - if (entered) setSource(DEFAULT_BACKGROUND_SOURCE); - }, [preset, spfBackgroundPreset]); + if (entered && descriptor.entrySource) setSource(descriptor.entrySource); + }, [media, descriptor.entrySource]); - // Constrain source away from DRM the preset cannot license, and away from a - // playback ID a non-Mux preset has no URL for. useEffect(() => { - if ((isDrmSource(source) || isMuxSource(source)) && !availableSources.includes(source)) { - setSource(DEFAULT_SOURCE); - } - }, [availableSources, source]); + if (!tailwindAvailable && styling === 'tailwind') setStyling('css'); + }, [tailwindAvailable, styling]); - // CDN, background video, and third-party embeds do not have a Tailwind skin variant. + // After a platform switch, a source that cannot load here or that lacks the styling falls back to what it can do. useEffect(() => { - if ((platform === 'cdn' || backgroundPreset || embedPreset) && styling === 'tailwind') { - setStyling('css'); - } - }, [platform, backgroundPreset, embedPreset, styling]); + if (skins !== undefined && !skinSourceAvailable(skins, platform)) setSkins(undefined); + else if (!skinStylingsAvailable.includes(styling)) setStyling(skinStylingsAvailable[0] ?? 'css'); + }, [skins, platform, skinStylingsAvailable, styling]); + + // A comparison the selection can no longer make, such as skins for a background media, switches off. + useEffect(() => { + if (compare !== 'off' && !compareAvailable(compare, selection)) setCompare('off'); + }, [compare, selection]); const handleSourceChange = useCallback((value: string) => setSource(value as SourceId), []); + const handleOptionsToggle = useCallback(() => { + setOptionsOpen((open) => { + storeOptionsOpen(!open); + + return !open; + }); + }, []); + + const handleOptionsClose = useCallback(() => { + storeOptionsOpen(false); + setOptionsOpen(false); + }, []); + + // Picking a styling an explicit source does not publish hands the choice back to that styling's default source. + const handleStylingChange = useCallback( + (value: Styling) => { + setStyling(value); + + if (skins !== undefined && !skinStylings(platform, skins).includes(value)) setSkins(undefined); + }, + [platform, skins] + ); + + // Picking a source that lacks the current styling switches to one it publishes. + const handleSkinsChange = useCallback( + (value: SkinSource) => { + setSkins(value); + + if (!skinStylings(platform, value).includes(styling)) setStyling('css'); + }, + [platform, styling] + ); + + const handleFrame = useCallback((id: string, frame: HTMLIFrameElement | null) => { + if (frame) frames.current.set(id, frame); + else frames.current.delete(id); + }, []); + + // The URL carried these too, but a change made while the page was still loading has no other way in. + const handleFrameLoad = (id: string) => { + const target = frames.current.get(id)?.contentWindow; + + if (target) postPreferences(target, frameParams); + }; + + const summary = summarizeSelection({ + platform, + media, + skin, + styling, + skins: skinSource, + width: playerWidth, + source, + }); + return (
- { - iframeRef.current?.contentWindow?.postMessage({ type: 'accent-color-change', accentColor }, '*'); - }} - /> +
+ + } + summary={summary} + report={{ build: { branch: __SANDBOX_BRANCH__, commit: __SANDBOX_COMMIT__ }, preferences, errors }} + params={frameParams} + onFrame={handleFrame} + onFrameLoad={handleFrameLoad} + /> + {optionsOpen && ( + + )} +
); } diff --git a/apps/sandbox/app/shell/navbar.tsx b/apps/sandbox/app/shell/navbar.tsx index f161782e37..96f2279b5f 100644 --- a/apps/sandbox/app/shell/navbar.tsx +++ b/apps/sandbox/app/shell/navbar.tsx @@ -1,162 +1,50 @@ -import type { SKINS } from '@app/constants'; -import { SANDBOX_LOCALE_OPTION_GROUPS, type SandboxLocaleTag } from '@app/shared/i18n/locale-meta'; -import { PRELOAD_VALUES, type PreloadValue } from '@app/shared/sandbox-listener'; +import type { CompareMode } from '@app/compare'; +import { SKIN_SOURCES, type SKINS } from '@app/constants'; +import { PLATFORM_LABELS, SKIN_LABELS, SKIN_SOURCE_LABELS, STYLING_LABELS } from '@app/labels'; +import { hasSkinChoice, hasTailwindSkin, MEDIA, MEDIA_IDS, type MediaId } from '@app/media'; +import { skinSourceAvailable, tailwindSkinAvailable } from '@app/shared/skin-sources'; import type { SandboxSource, SourceId } from '@app/shared/sources'; -import type { Platform, Preset, Skin, Styling } from '@app/types'; -import { useEffect, useId, useRef, useState } from 'react'; +import type { Platform, Skin, SkinSource, Styling } from '@app/types'; +import { useId } from 'react'; type NavbarProps = { platform: Platform; onPlatformChange: (value: Platform) => void; - styling: Styling; - onStylingChange: (value: Styling) => void; - preset: Preset; - onPresetChange: (value: Preset) => void; - skin: Skin; - onSkinChange: (value: Skin) => void; + media: MediaId; + onMediaChange: (value: MediaId) => void; source: SourceId; onSourceChange: (value: string) => void; - autoplay: boolean; - onAutoplayChange: (value: boolean) => void; - muted: boolean; - onMutedChange: (value: boolean) => void; - loop: boolean; - onLoopChange: (value: boolean) => void; - preload: PreloadValue; - onPreloadChange: (value: PreloadValue) => void; - locale: SandboxLocaleTag; - onLocaleChange: (value: SandboxLocaleTag) => void; - accentColor: string; - onAccentColorChange: (value: string) => void; availableSources: readonly SourceId[]; - isBackgroundVideo: boolean; - isSpfBackgroundVideo: boolean; - isSpfHls: boolean; - isMuxVideo: boolean; - isMuxAudio: boolean; - isEmbedMedia: boolean; platforms: readonly Platform[]; - stylings: readonly Styling[]; - presets: readonly Preset[]; sources: Record; + /** The options panel this bar's toggle opens and closes. */ + optionsId: string; + optionsOpen: boolean; + onOptionsToggle: () => void; }; -/** - * What the selected media will do with a source, when that's worth labelling for someone smoke-testing. The plain HLS - * presets are the SPF engine: no TS transmux pipeline and no EME, so it refuses MPEG-TS on format and encrypted - * renditions on protection. Derived from the pair rather than stored on the source, since every source here plays fine - * under some other media. - * - * Keyed on the _preset_, not a single is-SPF-HLS flag, because the variants answer differently and a note promising the - * wrong outcome is worse than none — a reviewer would file the difference as a bug: - * - * - **DRM.** Mux encrypts video renditions and leaves audio clear. The audio-only engine resolves only the audio - * rendition, so it never fetches an encrypted playlist and plays the source instead of refusing it. - * - **MPEG-TS.** Under audio-only, which specific failure depends on whether the source carries an audio rendition of its - * own or muxes audio into its video renditions — an absent type reports nothing and stalls silently rather than - * surfacing a verdict (see `internal/design/spf/features/errors.md`). Both mean nothing plays, so the note stops at - * that rather than naming a verdict that only appears for one of them. - */ -function expectedOutcomeNote(source: SandboxSource, preset: Preset): string | undefined { - // The background presets are the same engine again, error surface included: - // `collectErrors` is composed, the one-shot selection carries capability - // constraints, and the adapter promotes the first fatal condition. Nothing - // reaches the media element even so — MPEG-TS and encryption both leave - // `HTMLMediaElement.error` null, measured on Chromium and WebKit — so that - // promoted condition is the only signal there is. Kept separate from the plain - // HLS branch below because this composition's fatal set is wider: it is - // video-only, so an absent video type is fatal here too. - if (preset === 'hls-background-video' || preset === 'mux-background-video') { - if (source.drm) return 'expects protected error'; - - if (source.subType && source.subType !== 'mp4') return 'expects unsupported-format error'; - - return undefined; - } - - if (preset !== 'hls-video' && preset !== 'hls-audio') return undefined; - - const audioOnlyPreset = preset === 'hls-audio'; - - if (source.drm) { - return audioOnlyPreset ? 'plays — Mux leaves audio clear' : 'expects protected error'; - } - - if (source.subType && source.subType !== 'mp4') { - return audioOnlyPreset ? 'expects no playback' : 'expects unsupported-format error'; - } - - return undefined; -} - const SKIN_OPTIONS: readonly Skin[] = ['default', 'minimal'] satisfies readonly (typeof SKINS)[number][]; -const PLATFORM_LABELS: Record = { - html: 'HTML', - react: 'React', - cdn: 'CDN', -}; - -const PRESET_LABELS: Record = { - video: 'Video', - 'hlsjs-video': 'HLS Video (hls.js)', - 'native-hls-video': 'Native HLS Video', - 'mux-video': 'Mux Video', - 'mux-video-spf': 'Mux Video (SPF)', - 'mux-audio': 'Mux Audio', - 'mux-audio-spf': 'Mux Audio (SPF)', - 'hls-video': 'HLS Video', - 'hls-audio': 'HLS Audio', - 'dash-video': 'DASH Video', - 'shaka-video': 'Shaka Video', - audio: 'Audio', - 'background-video': 'Background Video', - 'hls-background-video': 'HLS Background Video (SPF)', - 'mux-background-video': 'Mux Background Video (SPF)', - 'vimeo-video': 'Vimeo Video', - 'youtube-video': 'YouTube Video', - 'cloudflare-video': 'Cloudflare Stream Video', - 'spotify-audio': 'Spotify Audio', - 'tiktok-video': 'TikTok Video', - 'twitch-video': 'Twitch Video', - 'wistia-video': 'Wistia Video', -}; +const ICON_BUTTON = + 'inline-flex size-8 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-950 aria-expanded:bg-zinc-100 aria-expanded:text-zinc-950 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:aria-expanded:bg-zinc-800 dark:aria-expanded:text-zinc-50'; +/** What plays: the platform, the media, and its source. The skin controls sit in the preview's header below. */ export function Navbar({ platform, onPlatformChange, - styling, - onStylingChange, - preset, - onPresetChange, - skin, - onSkinChange, + media, + onMediaChange, source, onSourceChange, - autoplay, - onAutoplayChange, - muted, - onMutedChange, - loop, - onLoopChange, - preload, - onPreloadChange, - locale, - onLocaleChange, - accentColor, - onAccentColorChange, availableSources, - isBackgroundVideo, - isSpfBackgroundVideo, - isSpfHls, - isMuxVideo, - isMuxAudio, - isEmbedMedia, platforms, - stylings, - presets, sources, + optionsId, + optionsOpen, + onOptionsToggle, }: NavbarProps) { + const { fixedSource, outcome } = MEDIA[media]; + return (
@@ -165,7 +53,7 @@ export function Navbar({
-
+
onStylingChange(v as Styling)} - options={stylings.map((s) => ({ - value: s, - label: s === 'css' ? 'CSS' : 'Tailwind', - disabled: s === 'tailwind' && (isBackgroundVideo || isEmbedMedia || platform === 'cdn'), - }))} - /> - - onSkinChange(v as Skin)} - options={SKIN_OPTIONS.map((s) => ({ value: s, label: capitalize(s) }))} - disabled={isBackgroundVideo} + label="Media" + value={media} + onChange={(v) => onMediaChange(v as MediaId)} + options={MEDIA_IDS.map((id) => ({ value: id, label: MEDIA[id].label }))} /> onChange(event.target.value)} - placeholder="Default" - spellCheck={false} - className="h-7 w-28 rounded border-none bg-white bg-clip-border px-2 text-[13px] font-medium text-zinc-950 shadow-xs ring shadow-black/20 ring-zinc-800/10 focus:outline-2 focus:outline-offset-2 focus:outline-zinc-950 dark:bg-zinc-900 dark:text-zinc-50 dark:ring-white/10 dark:focus:outline-zinc-50" - /> - onChange(event.target.value)} - aria-label="Choose accent color" - className="size-7 cursor-pointer rounded border-none bg-transparent p-0" - /> -
- - ); -} - -type CheckboxItemProps = { - id: string; - label: string; - checked: boolean; - onChange: (value: boolean) => void; -}; +
+ onChange(event.target.checked)} - className="size-3.5 cursor-pointer justify-self-start rounded border-zinc-300 accent-zinc-950 dark:border-zinc-700 dark:accent-zinc-50" + onSkinsChange(v as SkinSource)} + options={SKIN_SOURCES.map((value) => ({ + value, + label: SKIN_SOURCE_LABELS[value], + disabled: !skinSourceAvailable(value, platform), + }))} + disabled={!hasSkinChoice(media) || platform === 'cdn'} + /> -function SelectItem({ id, label, value, onChange, options, optionGroups }: SelectItemProps) { - return ( - <> - -
- - -
- + onChange(e.target.value)} disabled={disabled} - className="h-8 appearance-none rounded-md border-none bg-white bg-clip-border pr-8 pl-3 text-[13px] font-medium text-zinc-950 shadow-xs ring shadow-black/20 ring-zinc-800/10 transition-colors hover:bg-zinc-50 focus:outline-2 focus:outline-offset-2 focus:outline-zinc-950 disabled:pointer-events-none disabled:opacity-50 dark:bg-zinc-900 dark:text-zinc-50 dark:ring-white/10 dark:hover:bg-zinc-900 dark:focus:outline-zinc-50" + className={`${sizes.select} appearance-none border-none bg-white bg-clip-border font-medium text-zinc-950 shadow-xs ring shadow-black/20 ring-zinc-800/10 transition-colors hover:bg-zinc-50 focus:outline-2 focus:outline-offset-2 focus:outline-zinc-950 disabled:pointer-events-none disabled:opacity-50 dark:bg-zinc-900 dark:text-zinc-50 dark:ring-white/10 dark:hover:bg-zinc-900 dark:focus:outline-zinc-50`} > {options.map((opt) => (