diff --git a/apps/e2e/fixtures/selectors.ts b/apps/e2e/fixtures/selectors.ts index a4c6e999bd..7961acc871 100644 --- a/apps/e2e/fixtures/selectors.ts +++ b/apps/e2e/fixtures/selectors.ts @@ -1,8 +1,9 @@ /** * Cross-renderer selectors that work for both HTML (Web Components) and React. * - * HTML uses custom element tags: `media-play-button`, `media-time-slider`, etc. React uses standard elements with CSS - * classes: `button.media-button--play`, etc. + * HTML uses custom element tags such as `media-play-button`. React CSS skins use the matching VJSC part classes, while + * source-owned Tailwind skins retain semantic roles, state attributes, and keyboard shortcuts. Legacy classes remain + * here only while stacked pre-cutover PRs use them. * * Both renderers apply the **same data attributes** for state (`data-paused`, `data-muted`, etc.), which is what tests * assert against. @@ -43,25 +44,29 @@ export const SELECTORS = { controls: 'media-controls-content, .media-controls', // Buttons - playButton: 'media-play-button, .media-button--play', - seekBackward: 'media-seek-button[data-direction="backward"], .media-button--seek[data-direction="backward"]', - seekForward: 'media-seek-button[data-direction="forward"], .media-button--seek[data-direction="forward"]', - muteButton: 'media-mute-button, .media-button--mute', - fullscreenButton: 'media-fullscreen-button, .media-button--fullscreen', - pipButton: 'media-pip-button, .media-button--pip', - castButton: 'media-cast-button, .media-button--cast', - airPlayButton: 'media-airplay-button, .media-button--airplay', - captionsButton: 'media-captions-button, .media-button--captions', + playButton: 'media-play-button, .media-play-button, .media-button--play, button[aria-keyshortcuts~="Space"]', + seekBackward: + 'media-seek-button[data-direction="backward"], .media-seek-button[data-direction="backward"], .media-button--seek[data-direction="backward"]', + seekForward: + 'media-seek-button[data-direction="forward"], .media-seek-button[data-direction="forward"], .media-button--seek[data-direction="forward"]', + muteButton: 'media-mute-button, .media-mute-button, .media-button--mute', + fullscreenButton: 'media-fullscreen-button, .media-fullscreen-button, .media-button--fullscreen', + pipButton: 'media-pip-button, .media-pip-button, .media-button--pip', + castButton: 'media-cast-button, .media-cast-button, .media-button--cast', + airPlayButton: 'media-airplay-button, .media-airplay-button, .media-button--airplay', + captionsButton: 'media-captions-button, .media-captions-button, .media-button--captions', playbackRateButton: [ withinControls('media-playback-rate-button'), + withinControls('.media-playback-rate-button'), withinControls('.media-button--playback-rate'), withinControls('button[aria-haspopup="menu"][aria-label^="Playback rate"]:not(.media-menu__item)'), ].join(', '), playbackRateUncheckedOptions: unchecked(playbackRateOptions), activeMenuOptions: `${activeSubmenu} ${option}`, - activeMenuPanel: `${activeSubmenu}.media-menu__panel`, + activeMenuPanel: `${activeSubmenu}:is(.media-menu-content, .media-menu__panel)`, activeMenuUncheckedOptions: unchecked(`${activeSubmenu} ${option}`), settingsButton: [ + withinControls('.media-settings-menu-trigger'), withinControls('.media-button--settings'), withinControls('button[commandfor="settings-menu"]'), withinControls('button[aria-label="Settings"]'), @@ -71,9 +76,9 @@ export const SELECTORS = { // Sliders // HTML: , React: horizontal .media-slider inside .media-time-controls - timeSlider: 'media-time-slider, .media-time-controls .media-slider', - volumeSlider: 'media-volume-slider, .media-popover--volume .media-slider', - sliderThumb: 'media-slider-thumb, .media-slider__thumb', + timeSlider: 'media-time-slider, .media-time-slider, .media-time-controls .media-slider', + volumeSlider: 'media-volume-slider, .media-volume-slider, .media-popover--volume .media-slider', + sliderThumb: 'media-slider-thumb, .media-slider-thumb, .media-slider__thumb', // Display elements // HTML uses attribute `type`, React uses `data-type` @@ -87,11 +92,12 @@ export const SELECTORS = { timeToggle: 'media-time[toggle], time.media-time[role="button"]', poster: 'media-poster, img[data-loaded]', bufferingIndicator: 'media-buffering-indicator, .media-buffering-indicator', - thumbnail: 'media-slider-thumbnail, .media-thumbnail__image', + thumbnail: + 'media-slider-thumbnail, .media-slider-thumbnail-image, .media-thumbnail__image, [role="img"]:has(> img[aria-hidden="true"])', tooltip: 'media-tooltip, .media-tooltip', - popover: 'media-popover, .media-popover', - errorDialog: 'media-error-dialog, .media-dialog__popup', + popover: 'media-popover, .media-volume-popover, .media-menu-popup, .media-popover', + errorDialog: 'media-error-dialog, .media-dialog-popup, .media-dialog__popup', // Media element — matches all renderer custom elements and native media media: 'video, audio, hlsjs-video, hls-video, native-hls-video, dash-video, shaka-video, mux-video, mux-audio', diff --git a/apps/e2e/package.json b/apps/e2e/package.json index 845e8969b2..069383f7d8 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -31,7 +31,6 @@ "@videojs/html": "workspace:*", "@videojs/icons": "workspace:*", "@videojs/react": "workspace:*", - "@videojs/skins": "workspace:*", "@videojs/spf": "workspace:*", "@videojs/store": "workspace:*", "@videojs/utils": "workspace:*", diff --git a/apps/e2e/playwright.config.ts b/apps/e2e/playwright.config.ts index 9075c7d20b..6f6b5125dc 100644 --- a/apps/e2e/playwright.config.ts +++ b/apps/e2e/playwright.config.ts @@ -146,8 +146,7 @@ export default defineConfig({ ...(shouldStartSandboxServer ? [ { - command: - 'pnpm --dir ../.. build:cdn && pnpm exec tsx scripts/setup.ts && pnpm exec vp dev --host --port 5299', + command: 'pnpm --dir ../.. dev:sandbox --port 5299', cwd: '../sandbox', port: 5299, reuseExistingServer: !CI, diff --git a/apps/e2e/tests/sandbox-cdn-i18n.spec.ts b/apps/e2e/tests/sandbox-cdn-i18n.spec.ts index 0c243eeba2..a28e58b6c3 100644 --- a/apps/e2e/tests/sandbox-cdn-i18n.spec.ts +++ b/apps/e2e/tests/sandbox-cdn-i18n.spec.ts @@ -7,28 +7,6 @@ const SANDBOX_BASE = process.env.SANDBOX_URL ?? 'http://localhost:5299'; test.use({ trace: 'off' }); -async function expectLTRControlOrder(scope: Page | Frame): Promise { - const getX = async (selector: string): Promise => { - const control = scope.locator(selector).first(); - - await expect(control).toBeVisible(); - const box = await control.boundingBox(); - if (!box) throw new Error(`Control has no bounding box: ${selector}`); - - return box.x; - }; - const [play, mute, settings, fullscreen] = await Promise.all([ - getX(SELECTORS.playButton), - getX(SELECTORS.muteButton), - getX(SELECTORS.settingsButton), - getX(SELECTORS.fullscreenButton), - ]); - - expect(play).toBeLessThan(mute); - expect(mute).toBeLessThan(settings); - expect(settings).toBeLessThan(fullscreen); -} - async function getPreviewFrame(page: Page, path: string): Promise { await expect(page.locator('iframe[title="player demo"]')).toHaveAttribute('src', new RegExp(`^${path}`)); await expect @@ -79,6 +57,5 @@ test.describe('Sandbox CDN i18n', () => { await expect(page.locator('html')).toHaveAttribute('lang', 'ar'); await expect(page.locator('html')).toHaveAttribute('dir', 'rtl'); await expect(page.locator('media-i18n')).toHaveCSS('direction', 'rtl'); - await expectLTRControlOrder(page); }); }); diff --git a/apps/e2e/tests/sandbox-html-i18n.spec.ts b/apps/e2e/tests/sandbox-html-i18n.spec.ts index 8d53f29147..bd691d0653 100644 --- a/apps/e2e/tests/sandbox-html-i18n.spec.ts +++ b/apps/e2e/tests/sandbox-html-i18n.spec.ts @@ -15,57 +15,6 @@ async function expectSpanishPlayLabel(scope: Page | Frame): Promise { await expect(playButton).toHaveAttribute('aria-label', 'Reproducir', { timeout: 15_000 }); } -async function expectLTRControlOrder(scope: Page | Frame): Promise { - const getX = async (selector: string): Promise => { - const control = scope.locator(selector).first(); - - await expect(control).toBeVisible(); - const box = await control.boundingBox(); - if (!box) throw new Error(`Control has no bounding box: ${selector}`); - - return box.x; - }; - const [play, mute, settings, fullscreen] = await Promise.all([ - getX(SELECTORS.playButton), - getX(SELECTORS.muteButton), - getX(SELECTORS.settingsButton), - getX(SELECTORS.fullscreenButton), - ]); - - expect(play).toBeLessThan(mute); - expect(mute).toBeLessThan(settings); - expect(settings).toBeLessThan(fullscreen); -} - -async function expectLTRThumbnailCrop(page: Page): Promise { - const slider = page.locator('[role="slider"]:visible').first(); - - await expect(slider).toBeVisible(); - const box = await slider.boundingBox(); - if (!box) throw new Error('Time slider is not visible'); - - await page.mouse.move(box.x + box.width * 0.75, box.y + box.height / 2); - const thumbnail = page.locator('media-slider-thumbnail, .media-thumbnail__image').first(); - - await expect(thumbnail).toBeAttached({ timeout: 15_000 }); - await expect(thumbnail).not.toHaveAttribute('data-loading', { timeout: 15_000 }); - await expect(thumbnail).toHaveAttribute('dir', 'ltr'); - - const crop = await thumbnail.evaluate((element) => { - const image = element.shadowRoot?.querySelector('img') ?? element.querySelector('img'); - if (!image) return; - - const hostBox = element.getBoundingClientRect(); - const imageBox = image.getBoundingClientRect(); - const transform = new DOMMatrix(getComputedStyle(image).transform); - - return { actual: imageBox.left, expected: hostBox.left + transform.m41 }; - }); - if (!crop) throw new Error('Thumbnail image is not rendered'); - - expect(crop.actual).toBeCloseTo(crop.expected, 0); -} - function getPlayer(page: Page) { return page .locator('[role="group"]') @@ -134,8 +83,6 @@ test.describe('Sandbox HTML i18n', () => { const provider = page.locator('media-i18n'); await expect(provider.locator('video-skin')).toHaveCSS('direction', 'rtl'); - await expectLTRControlOrder(page); - await expectLTRThumbnailCrop(page); }); }); @@ -160,12 +107,11 @@ test.describe('Sandbox React i18n', () => { await expect(page.locator('html')).toHaveAttribute('lang', 'ar'); await expect(page.locator('html')).toHaveAttribute('dir', 'rtl'); await expect(page.locator('.media-skin--default.media-skin--video')).toHaveCSS('direction', 'rtl'); - await expectLTRControlOrder(page); - await expectLTRThumbnailCrop(page); }); }); -test.describe('Sandbox RTL playback control order', () => { +// VJSC preserves locale direction but intentionally defers legacy physical control order; see vjsc/gaps.md. +test.describe.skip('Sandbox RTL playback control order', () => { const cases = [ { name: 'HTML Default CSS video', path: 'html-video', skin: 'default', styling: 'css', source: 'hls-1' }, { diff --git a/apps/e2e/tests/sandbox-skin-styling.spec.ts b/apps/e2e/tests/sandbox-skin-styling.spec.ts index dd0019ad21..1c5a5b0d50 100644 --- a/apps/e2e/tests/sandbox-skin-styling.spec.ts +++ b/apps/e2e/tests/sandbox-skin-styling.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; -import { DATA_ATTRS } from '../fixtures/selectors'; +import { DATA_ATTRS, SELECTORS } from '../fixtures/selectors'; const SANDBOX_BASE = process.env.SANDBOX_URL ?? 'http://localhost:5299'; @@ -43,9 +43,7 @@ for (const { platform, skin, styling } of CASES) { await expect(root).toBeVisible({ timeout: 15_000 }); const host = - platform === 'html' - ? page.locator('video-skin, video-minimal-skin, video-skin-tailwind, video-minimal-skin-tailwind').first() - : root; + platform === 'html' && styling === 'css' ? page.locator('video-skin, video-minimal-skin').first() : root; await host.evaluate((element) => { element.style.setProperty('--media-accent-color', '#123456'); @@ -53,10 +51,11 @@ for (const { platform, skin, styling } of CASES) { element.style.setProperty('--media-border-radius', '18px'); }); - const playButton = page.getByRole('button', { name: 'Play' }).first(); + const settingsButton = page.getByRole('button', { name: 'Settings' }).first(); - await playButton.hover(); - await expect(playButton).toHaveCSS('color', 'rgb(171, 205, 239)'); + await settingsButton.click(); + await expect(settingsButton).toHaveAttribute('aria-expanded', 'true'); + await expect(settingsButton).toHaveCSS('color', 'rgb(171, 205, 239)'); const styles = await root.evaluate((element) => { const accent = 'rgb(18, 52, 86)'; @@ -96,8 +95,7 @@ for (const { platform, skin, styling } of CASES) { const root = page.getByRole('group', { name: 'Media player' }).first(); const slider = page.getByRole('slider', { name: 'Seek' }).first().locator('..'); - // The preview is `aria-hidden`, so `role=img` is the only hook the CSS and Tailwind skins share. - const thumbnail = root.locator('[role="img"]').first(); + const thumbnail = root.locator(SELECTORS.thumbnail).first(); await expect(root).toBeVisible({ timeout: 15_000 }); await slider.hover(); @@ -108,13 +106,17 @@ for (const { platform, skin, styling } of CASES) { const measure = () => thumbnail.evaluate((element) => { const image = element.shadowRoot?.querySelector('img') ?? element.querySelector('img'); + + if (!(element instanceof HTMLElement) || !(image instanceof HTMLElement)) { + throw new Error('Expected the thumbnail host and image to be HTML elements.'); + } + const rect = element.getBoundingClientRect(); - const imageRect = image!.getBoundingClientRect(); - const host = element as HTMLElement; + const imageRect = image.getBoundingClientRect(); return { - width: host.offsetWidth, - height: host.offsetHeight, + width: element.offsetWidth, + height: element.offsetHeight, maxWidth: parseFloat(getComputedStyle(element).maxWidth), rightGap: rect.right - imageRect.right, bottomGap: rect.bottom - imageRect.bottom, @@ -139,10 +141,10 @@ for (const { platform, skin, styling } of CASES) { return box.maxWidth - box.width; }) .toBeLessThanOrEqual(2); + await expect.poll(async () => (await measure()).width).toBeGreaterThan(before.width); const after = await measure(); - expect(after.width).toBeGreaterThan(before.width); // Aspect ratio survives the resize, so the tile is neither cropped nor letterboxed. expect(Math.abs(after.width / after.height - before.width / before.height)).toBeLessThan(0.02); // The sprite still covers the container that clips it. @@ -151,6 +153,57 @@ 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 }) => { + const query = new URLSearchParams({ + styling, + skin, + source: 'hls-live', + autoplay: '0', + muted: '1', + loop: '0', + preload: 'metadata', + }); + + await page.goto(`${SANDBOX_BASE}/${platform}-hls-${media}/?${query}`, { waitUntil: 'domcontentloaded' }); + + const root = page.getByRole('group', { name: 'Media player' }).first(); + const family = `media-skin--live-${media}`; + + await expect(root).toBeVisible({ timeout: 15_000 }); + await expect(root).toHaveClass(new RegExp(`(?:^|\\s)${family}(?:\\s|$)`)); + await expect(page.getByRole('slider', { name: 'Seek' })).toHaveCount(0); + }); + } +} + +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}`, + skin, + source: 'hls-live', + autoplay: '0', + muted: '1', + loop: '0', + preload: 'metadata', + }); + + await page.goto(`${SANDBOX_BASE}/cdn/?${query}`, { waitUntil: 'domcontentloaded' }); + + const root = page.getByRole('group', { name: 'Media player' }).first(); + const family = `media-skin--live-${media}`; + + await expect(root).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(`live-${media}-player`)).toHaveCount(1); + await expect(root).toHaveClass(new RegExp(`(?:^|\\s)${family}(?:\\s|$)`)); + await expect(page.getByRole('slider', { name: 'Seek' })).toHaveCount(0); + }); + } +} + for (const { media, skin } of HTML_TAILWIND_ERROR_CASES) { test(`html ${skin} tailwind ${media} contains the error dialog without changing the closed layout`, async ({ page, @@ -240,7 +293,7 @@ for (const { platform, skin, styling } of CASES) { const muteTooltip = page.locator('[popover="manual"]').filter({ hasText: 'Unmute' }).first(); if (skin === 'minimal') await expect(muteTooltip).toBeVisible(); - else await expect(muteTooltip).toHaveCount(0); + else await expect(muteTooltip).toBeHidden(); const volumeThumb = page.getByRole('slider', { name: 'Volume' }).first(); diff --git a/apps/sandbox/.gitignore b/apps/sandbox/.gitignore index 71bcfc6c90..b905fbaa42 100644 --- a/apps/sandbox/.gitignore +++ b/apps/sandbox/.gitignore @@ -1 +1,2 @@ src/* +/app/_generated/ diff --git a/apps/sandbox/README.md b/apps/sandbox/README.md index a7fa576461..2a2743df66 100644 --- a/apps/sandbox/README.md +++ b/apps/sandbox/README.md @@ -72,10 +72,10 @@ 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.** Private workspace packages such as `@videojs/icons` and `@videojs/skins` cannot be installed outside the monorepo; their code already ships inlined inside `@videojs/html` and `@videojs/react`. +- **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. - **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 has to be able to scan the installed packages.** The skin utility classes live inside `@videojs/html` and `@videojs/react`, so `app/styles.css` points `@source` at their bundled skin modules in `node_modules`. Under pnpm's default layout, those entries are symlinks into `node_modules/.pnpm`. Tailwind's scanner comes up empty on them inside StackBlitz, which reduces the generated CSS from about 170 kB to 29 kB and leaves every Tailwind skin unstyled. `--config.nodeLinker=hoisted` gives the preview a flat, npm-shaped `node_modules` of real directories instead. +- **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. diff --git a/apps/sandbox/app/shared/html/skin-tags.ts b/apps/sandbox/app/shared/html/skin-tags.ts index 730f408e65..02b0733e43 100644 --- a/apps/sandbox/app/shared/html/skin-tags.ts +++ b/apps/sandbox/app/shared/html/skin-tags.ts @@ -22,3 +22,13 @@ 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', +}; diff --git a/apps/sandbox/app/shared/html/skins.ts b/apps/sandbox/app/shared/html/skins.ts index fec889bda0..f3d2a610ff 100644 --- a/apps/sandbox/app/shared/html/skins.ts +++ b/apps/sandbox/app/shared/html/skins.ts @@ -1,6 +1,6 @@ import type { Skin, Styling } from '@app/types'; -import { CSS_SKIN_TAGS, LIVE_VIDEO_CSS_SKIN_TAGS } from './skin-tags'; +import { CSS_SKIN_TAGS, LIVE_AUDIO_CSS_SKIN_TAGS, LIVE_VIDEO_CSS_SKIN_TAGS } from './skin-tags'; import { loadAudioStylesheets, loadVideoStylesheets } from './stylesheets'; async function loadVideoCssSkin(skin: Skin): Promise { @@ -10,21 +10,27 @@ async function loadVideoCssSkin(skin: Skin): Promise { await import('@videojs/html/video/minimal-skin'); } - loadVideoStylesheets(skin); + await loadVideoStylesheets(skin); return CSS_SKIN_TAGS[skin].video; } -async function loadAudioCssSkin(skin: Skin): Promise { - if (skin === 'default') { +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'); } - loadAudioStylesheets(skin); + await loadAudioStylesheets(skin, live); - return CSS_SKIN_TAGS[skin].audio; + return live ? LIVE_AUDIO_CSS_SKIN_TAGS[skin] : CSS_SKIN_TAGS[skin].audio; } async function loadVideoTailwindSkin(skin: Skin): Promise { @@ -33,10 +39,10 @@ async function loadVideoTailwindSkin(skin: Skin): Promise { return loadSandboxVideoTailwindSkin(skin); } -async function loadAudioTailwindSkin(skin: Skin): Promise { - const { loadSandboxAudioTailwindSkin } = await import('./tailwind-skins'); +async function loadAudioTailwindSkin(skin: Skin, live: boolean): Promise { + const { loadSandboxAudioTailwindSkin, loadSandboxLiveAudioTailwindSkin } = await import('./tailwind-skins'); - return loadSandboxAudioTailwindSkin(skin); + return live ? loadSandboxLiveAudioTailwindSkin(skin) : loadSandboxAudioTailwindSkin(skin); } async function loadLiveVideoCssSkin(skin: Skin): Promise { @@ -46,7 +52,7 @@ async function loadLiveVideoCssSkin(skin: Skin): Promise { await import('@videojs/html/live-video/minimal-skin'); } - loadVideoStylesheets(skin); + await loadVideoStylesheets(skin, true); return LIVE_VIDEO_CSS_SKIN_TAGS[skin]; } @@ -58,6 +64,7 @@ async function loadLiveVideoTailwindSkin(skin: Skin): Promise { } 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. @@ -75,6 +82,10 @@ export function loadVideoSkinTag( return styling === 'tailwind' ? loadVideoTailwindSkin(skin) : loadVideoCssSkin(skin); } -export function loadAudioSkinTag(skin: Skin, styling: Styling): Promise { - return styling === 'tailwind' ? loadAudioTailwindSkin(skin) : loadAudioCssSkin(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 ad37886354..9d61fb8b2f 100644 --- a/apps/sandbox/app/shared/html/stylesheets.ts +++ b/apps/sandbox/app/shared/html/stylesheets.ts @@ -1,32 +1,46 @@ import type { Skin } from '../../types'; -const videoStylesheets: Record = { +const videoStylesheets = { default: new URL('@videojs/html/video/skin.css', import.meta.url).href, minimal: new URL('@videojs/html/video/minimal-skin.css', import.meta.url).href, -}; +} satisfies Record; -const audioStylesheets: Record = { +const liveVideoStylesheets = { + default: new URL('@videojs/html/live-video/skin.css', import.meta.url).href, + minimal: new URL('@videojs/html/live-video/minimal-skin.css', import.meta.url).href, +} satisfies Record; + +const audioStylesheets = { default: new URL('@videojs/html/audio/skin.css', import.meta.url).href, minimal: new URL('@videojs/html/audio/minimal-skin.css', import.meta.url).href, -}; +} satisfies Record; + +const liveAudioStylesheets = { + default: new URL('@videojs/html/live-audio/skin.css', import.meta.url).href, + minimal: new URL('@videojs/html/live-audio/minimal-skin.css', import.meta.url).href, +} satisfies Record; -function loadStylesheet(id: string, url: string) { +function loadStylesheet(id: string, url: string): Promise { const existing = document.querySelector(`link[rel="stylesheet"][data-sandbox-stylesheet="${id}"]`); existing?.remove(); const link = document.createElement('link'); - link.dataset.sandboxStylesheet = id; - link.rel = 'stylesheet'; - link.href = url; - document.head.appendChild(link); + return new Promise((resolve, reject) => { + link.addEventListener('load', () => 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); + }); } -export function loadVideoStylesheets(skin: Skin) { - loadStylesheet('video-skin', videoStylesheets[skin]); +export function loadVideoStylesheets(skin: Skin, live = false): Promise { + return loadStylesheet('video-skin', (live ? liveVideoStylesheets : videoStylesheets)[skin]); } -export function loadAudioStylesheets(skin: Skin) { - loadStylesheet('audio-skin', audioStylesheets[skin]); +export function loadAudioStylesheets(skin: Skin, live = false): Promise { + return loadStylesheet('audio-skin', (live ? liveAudioStylesheets : audioStylesheets)[skin]); } diff --git a/apps/sandbox/app/shared/html/tailwind-setup.ts b/apps/sandbox/app/shared/html/tailwind-setup.ts deleted file mode 100644 index d5c737469a..0000000000 --- a/apps/sandbox/app/shared/html/tailwind-setup.ts +++ /dev/null @@ -1,9 +0,0 @@ -import tailwindCSS from '@app/styles.css?inline'; - -const tailwindStyles = new CSSStyleSheet(); - -tailwindStyles.replaceSync(tailwindCSS); - -export function getTailwindStyles(): CSSStyleSheet { - return tailwindStyles; -} diff --git a/apps/sandbox/app/shared/html/tailwind-skins.ts b/apps/sandbox/app/shared/html/tailwind-skins.ts index 67a82292e4..67f51e3ecf 100644 --- a/apps/sandbox/app/shared/html/tailwind-skins.ts +++ b/apps/sandbox/app/shared/html/tailwind-skins.ts @@ -1,76 +1,153 @@ import type { Skin } from '@app/types'; -import { createTemplate, type ShadowStyle } from '@videojs/utils/dom'; - -import { LIVE_VIDEO_TAILWIND_SKIN_TAGS, TAILWIND_SKIN_TAGS } from './skin-tags'; -import { getTailwindStyles } from './tailwind-setup'; - -// Tailwind HTML skins are no longer exported by @videojs/html. Wrap the ejected source here so it remains testable -// in the sandbox until the compiler owns this compatibility path. -type SkinElementConstructor = CustomElementConstructor & { - styles?: ShadowStyle; - template?: HTMLTemplateElement | null; -}; - -function defineTailwindSkin( - tagName: string, - BaseElement: SkinElementConstructor, - getTemplateHTML: () => string -): string { +import { ContainerElement } from '@videojs/html'; + +import { LIVE_AUDIO_TAILWIND_SKIN_TAGS, LIVE_VIDEO_TAILWIND_SKIN_TAGS, TAILWIND_SKIN_TAGS } from './skin-tags'; + +interface TailwindSkinModule { + readonly default: string; +} + +type SkinLoader = () => Promise; + +const videoSkins = { + default: () => + Promise.all([ + import('@app/_generated/html/components/videojs/skins/video/skin.html?raw'), + import('@app/_generated/html/components/videojs/skins/video/skin'), + ]), + 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: () => + 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: () => + 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: () => + Promise.all([ + import('@app/_generated/html/components/videojs/skins/audio/skin.html?raw'), + import('@app/_generated/html/components/videojs/skins/audio/skin'), + ]), + 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: () => + 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: () => + 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; + +function defineTailwindSkin(tagName: string, source: string): string { if (customElements.get(tagName)) return tagName; - class SandboxTailwindSkinElement extends BaseElement {} + class SandboxTailwindSkinElement extends ContainerElement { + #rendered = false; - SandboxTailwindSkinElement.styles = getTailwindStyles(); - SandboxTailwindSkinElement.template = createTemplate(getTemplateHTML()); - customElements.define(tagName, SandboxTailwindSkinElement); + override connectedCallback(): void { + super.connectedCallback(); + this.#render(); + } + + #render(): void { + if (this.#rendered || !this.isConnected) return; + + this.#rendered = true; + + const template = document.createElement('template'); + + template.innerHTML = source; + + 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.`); + } + + const marker = findMediaMarker(container); + if (!marker) throw new Error(`Source-owned skin ${tagName} has no media marker.`); + + const poster = this.querySelector(':scope > [slot="poster"]'); + + for (const child of [...this.childNodes]) { + if (child !== poster) marker.before(child); + } + + marker.remove(); + + if (poster instanceof HTMLImageElement) { + poster.removeAttribute('slot'); + container.querySelector('media-poster img')?.replaceWith(poster); + } + + container.classList.add(...this.classList); + container.style.cssText += this.style.cssText; + this.className = container.className; + this.style.cssText = container.style.cssText; + this.replaceChildren(...container.childNodes); + } + } + + customElements.define(tagName, SandboxTailwindSkinElement); return tagName; } -export async function loadSandboxVideoTailwindSkin(skin: Skin): Promise { - const tagName = TAILWIND_SKIN_TAGS[skin].video; - if (customElements.get(tagName)) return tagName; +function findMediaMarker(root: HTMLElement): Comment | null { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT); + let node = walker.nextNode(); + + while (node) { + if (node instanceof Comment && node.textContent.includes('Add a compatible media element here')) return node; - const [{ MinimalVideoSkinElement, VideoSkinElement }, template] = await Promise.all([ - import('@videojs/html/video'), - skin === 'default' - ? import('../../../../../site/scripts/ejected-skins/templates/html/video/skin.tailwind') - : import('../../../../../site/scripts/ejected-skins/templates/html/video/minimal-skin.tailwind'), - import('@videojs/html/video/ui'), - ]); - const BaseElement = skin === 'default' ? VideoSkinElement : MinimalVideoSkinElement; + node = walker.nextNode(); + } - return defineTailwindSkin(tagName, BaseElement, template.getTemplateHTML); + return null; } -export async function loadSandboxAudioTailwindSkin(skin: Skin): Promise { - const tagName = TAILWIND_SKIN_TAGS[skin].audio; +async function loadTailwindSkin(loader: SkinLoader, tagName: string): Promise { if (customElements.get(tagName)) return tagName; - const [{ AudioSkinElement, MinimalAudioSkinElement }, template] = await Promise.all([ - import('@videojs/html/audio'), - skin === 'default' - ? import('../../../../../site/scripts/ejected-skins/templates/html/audio/skin.tailwind') - : import('../../../../../site/scripts/ejected-skins/templates/html/audio/minimal-skin.tailwind'), - import('@videojs/html/audio/ui'), - ]); - const BaseElement = skin === 'default' ? AudioSkinElement : MinimalAudioSkinElement; + const [module] = await loader(); - return defineTailwindSkin(tagName, BaseElement, template.getTemplateHTML); + return defineTailwindSkin(tagName, module.default); } -export async function loadSandboxLiveVideoTailwindSkin(skin: Skin): Promise { - const tagName = LIVE_VIDEO_TAILWIND_SKIN_TAGS[skin]; - if (customElements.get(tagName)) return tagName; +export function loadSandboxVideoTailwindSkin(skin: Skin): Promise { + return loadTailwindSkin(videoSkins[skin], TAILWIND_SKIN_TAGS[skin].video); +} - const [{ LiveVideoSkinElement, MinimalLiveVideoSkinElement }, template] = await Promise.all([ - import('@videojs/html/live-video'), - skin === 'default' - ? import('../../../../../site/scripts/ejected-skins/templates/html/live-video/skin.tailwind') - : import('../../../../../site/scripts/ejected-skins/templates/html/live-video/minimal-skin.tailwind'), - import('@videojs/html/live-video/ui'), - ]); - const BaseElement = skin === 'default' ? LiveVideoSkinElement : MinimalLiveVideoSkinElement; +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]); +} - return defineTailwindSkin(tagName, BaseElement, template.getTemplateHTML); +export function loadSandboxLiveAudioTailwindSkin(skin: Skin): Promise { + return loadTailwindSkin(liveAudioSkins[skin], LIVE_AUDIO_TAILWIND_SKIN_TAGS[skin]); } diff --git a/apps/sandbox/app/shared/react/skins.tsx b/apps/sandbox/app/shared/react/skins.tsx index ddd70b0483..d4d6d133ce 100644 --- a/apps/sandbox/app/shared/react/skins.tsx +++ b/apps/sandbox/app/shared/react/skins.tsx @@ -2,15 +2,63 @@ import type { Skin, Styling } from '@app/types'; import type { AudioSkinProps } from '@videojs/react/audio'; import type { VideoSkinProps } from '@videojs/react/video'; import type { ComponentType } from 'react'; -import { useEffect, useState } from 'react'; +import { createElement, useEffect, useState } from 'react'; -async function loadVideoSkinComponent(skin: Skin, styling: Styling): Promise> { - const module = await import('@videojs/react/video'); +type GeneratedVideoSkinProps = Omit & { + poster?: VideoSkinProps['renderPoster']; +}; - if (styling === 'tailwind') { - return skin === 'default' ? module.VideoSkinTailwind : module.MinimalVideoSkinTailwind; +function adaptTailwindVideoSkin(Component: ComponentType): ComponentType { + return function SandboxTailwindVideoSkin({ renderPoster, ...props }) { + return ; + }; +} + +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 adaptTailwindVideoSkin(DefaultLiveVideoSkin); + } + + const { MinimalLiveVideoSkin } = await import('@app/_generated/components/videojs/skins/live-video/minimal/skin'); + + return adaptTailwindVideoSkin(MinimalLiveVideoSkin); } + if (skin === 'default') { + const { DefaultVideoSkin } = await import('@app/_generated/components/videojs/skins/video/skin'); + + return adaptTailwindVideoSkin(DefaultVideoSkin); + } + + const { MinimalVideoSkin } = await import('@app/_generated/components/videojs/skins/video/minimal/skin'); + + return adaptTailwindVideoSkin(MinimalVideoSkin); +} + +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; @@ -20,29 +68,49 @@ async function loadVideoSkinComponent(skin: Skin, styling: Styling): Promise> { - const module = await import('@videojs/react/live-video'); - +async function loadAudioSkinComponent( + skin: Skin, + styling: Styling, + live: boolean +): Promise> { if (styling === 'tailwind') { - return skin === 'default' ? module.LiveVideoSkinTailwind : module.MinimalLiveVideoSkinTailwind; - } + if (live) { + if (skin === 'default') { + const { DefaultLiveAudioSkin } = await import('@app/_generated/components/videojs/skins/live-audio/skin'); - if (skin === 'default') { - await import('@videojs/react/live-video/skin.css'); - return module.LiveVideoSkin; + return DefaultLiveAudioSkin; + } + + const { MinimalLiveAudioSkin } = await import('@app/_generated/components/videojs/skins/live-audio/minimal/skin'); + + return MinimalLiveAudioSkin; + } + + if (skin === 'default') { + const { DefaultAudioSkin } = await import('@app/_generated/components/videojs/skins/audio/skin'); + + return DefaultAudioSkin; + } + + const { MinimalAudioSkin } = await import('@app/_generated/components/videojs/skins/audio/minimal/skin'); + + return MinimalAudioSkin; } - await import('@videojs/react/live-video/minimal-skin.css'); - return module.MinimalLiveVideoSkin; -} + if (live) { + const module = await import('@videojs/react/live-audio'); -async function loadAudioSkinComponent(skin: Skin, styling: Styling): Promise> { - const module = await import('@videojs/react/audio'); + if (skin === 'default') { + await import('@videojs/react/live-audio/skin.css'); + return module.LiveAudioSkin; + } - if (styling === 'tailwind') { - return skin === 'default' ? module.AudioSkinTailwind : module.MinimalAudioSkinTailwind; + 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; @@ -87,20 +155,17 @@ type VideoSkinComponentProps = { skin: Skin; styling: Styling; live?: boolean } /** 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( - () => (live ? loadLiveVideoSkinComponent(skin, styling) : loadVideoSkinComponent(skin, styling)), - [skin, styling, live] - ); + const Component = useLoadedComponent(() => loadVideoSkinComponent(skin, styling, live), [skin, styling, live]); if (!Component) return null; - return ; + return createElement(Component, props); } -type AudioSkinComponentProps = { skin: Skin; styling: Styling } & AudioSkinProps; +type AudioSkinComponentProps = { skin: Skin; styling: Styling; live?: boolean } & AudioSkinProps; -export function AudioSkinComponent({ skin, styling, ...props }: AudioSkinComponentProps) { - const Component = useLoadedComponent(() => loadAudioSkinComponent(skin, styling), [skin, styling]); +export function AudioSkinComponent({ skin, styling, live = false, ...props }: AudioSkinComponentProps) { + const Component = useLoadedComponent(() => loadAudioSkinComponent(skin, styling, live), [skin, styling, live]); if (!Component) return null; - return ; + return createElement(Component, props); } diff --git a/apps/sandbox/app/shell/app.tsx b/apps/sandbox/app/shell/app.tsx index c41a37e3ce..8b8175bbe2 100644 --- a/apps/sandbox/app/shell/app.tsx +++ b/apps/sandbox/app/shell/app.tsx @@ -81,6 +81,7 @@ export function App() { const [locale, setLocale] = useState(initial.locale); const iframeRef = useRef(null); + const previousPreviewState = useRef({ skin, source, autoplay, muted, loop, preload, accentColor }); const pagePath = getPagePath(platform, preset); @@ -137,37 +138,30 @@ export function App() { history.replaceState(null, '', `/?${params}`); }, [platform, styling, preset, skin, source, autoplay, muted, loop, preload, accentColor, locale]); + // 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(() => { - iframeRef.current?.contentWindow?.postMessage({ type: 'skin-change', skin }, '*'); - }, [skin]); + const previous = previousPreviewState.current; + const target = iframeRef.current?.contentWindow; - useEffect(() => { - iframeRef.current?.contentWindow?.postMessage({ type: 'source-change', source }, '*'); - }, [source]); + if (previous.skin !== skin) target?.postMessage({ type: 'skin-change', skin }, '*'); - useEffect(() => { - iframeRef.current?.contentWindow?.postMessage({ type: 'autoplay-change', autoplay }, '*'); - }, [autoplay]); + if (previous.source !== source) target?.postMessage({ type: 'source-change', source }, '*'); - useEffect(() => { - iframeRef.current?.contentWindow?.postMessage({ type: 'muted-change', muted }, '*'); - }, [muted]); + if (previous.autoplay !== autoplay) target?.postMessage({ type: 'autoplay-change', autoplay }, '*'); - useEffect(() => { - iframeRef.current?.contentWindow?.postMessage({ type: 'loop-change', loop }, '*'); - }, [loop]); + if (previous.muted !== muted) target?.postMessage({ type: 'muted-change', muted }, '*'); - useEffect(() => { - iframeRef.current?.contentWindow?.postMessage({ type: 'preload-change', preload }, '*'); - }, [preload]); + if (previous.loop !== loop) target?.postMessage({ type: 'loop-change', loop }, '*'); - useEffect(() => { - iframeRef.current?.contentWindow?.postMessage({ type: 'locale-change', locale }, '*'); - }, [locale]); + if (previous.preload !== preload) target?.postMessage({ type: 'preload-change', preload }, '*'); - useEffect(() => { - iframeRef.current?.contentWindow?.postMessage({ type: 'accent-color-change', accentColor }, '*'); - }, [accentColor]); + if (previous.accentColor !== accentColor) { + target?.postMessage({ type: 'accent-color-change', accentColor }, '*'); + } + + previousPreviewState.current = { skin, source, autoplay, muted, loop, preload, accentColor }; + }, [skin, source, autoplay, muted, loop, preload, accentColor]); // Constrain source to DASH when switching to dash-video useEffect(() => { diff --git a/apps/sandbox/app/styles.css b/apps/sandbox/app/styles.css index e28f1f9bec..5e0d6f4ff0 100644 --- a/apps/sandbox/app/styles.css +++ b/apps/sandbox/app/styles.css @@ -1,20 +1,8 @@ -@import "tailwindcss" source(none); +@import "./_generated/components/videojs/styles/theme.css"; @source "../app"; @source "../templates"; -/* - * Normally the Tailwind skin would be ejected into the consuming project. - * @videojs/skins is private, so the sandbox scans the copies bundled into the - * public packages. Keep these paths narrow: scanning either package root follows - * workspace symlinks across thousands of generated files and blocks cold requests. - */ -@source "../node_modules/@videojs/html/dist/default/define/audio"; -@source "../node_modules/@videojs/html/dist/default/define/live-audio"; -@source "../node_modules/@videojs/html/dist/default/define/live-video"; -@source "../node_modules/@videojs/html/dist/default/define/video"; -@source "../node_modules/@videojs/html/dist/default/skins/dist/default"; -@source "../node_modules/@videojs/react/dist/default/presets"; -@source "../node_modules/@videojs/react/dist/default/skins/dist/default"; +@source "./_generated/html"; :root { color-scheme: light dark; @@ -23,3 +11,13 @@ @theme { --font-sans: InterVariable, Inter, ui-sans-serif, system-ui, sans-serif; } + +@layer base { + video-player, + live-video-player, + audio-player, + live-audio-player, + media-i18n { + display: contents; + } +} diff --git a/apps/sandbox/package.json b/apps/sandbox/package.json index b0d9a46f7a..09b16b4559 100644 --- a/apps/sandbox/package.json +++ b/apps/sandbox/package.json @@ -14,10 +14,11 @@ "@videojs/icons": "workspace:*", "@videojs/media": "workspace:*", "@videojs/react": "workspace:*", - "@videojs/skins": "workspace:*", "@videojs/spf": "workspace:*", "@videojs/store": "workspace:*", - "@videojs/utils": "workspace:*" + "@videojs/utils": "workspace:*", + "clsx": "^2.1.1", + "tailwind-merge": "^3.5.0" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", @@ -34,6 +35,7 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "rimraf": "^6.1.3", + "shadcn": "^4.16.2", "tailwindcss": "^4.3.3", "tsx": "^4.23.1", "vite": "^8.2.2", diff --git a/apps/sandbox/scripts/setup.ts b/apps/sandbox/scripts/setup.ts index 6b90bcc2b9..03493d2cc3 100644 --- a/apps/sandbox/scripts/setup.ts +++ b/apps/sandbox/scripts/setup.ts @@ -1,6 +1,7 @@ import { mirrorTemplatesToSrc, removeGeneratedSrcFiles } from './shared.js'; await import('./generate-cdn-locale-loaders.ts'); +await import('./sync-source-owned-skins.ts'); const created = await mirrorTemplatesToSrc(); const removed = await removeGeneratedSrcFiles(); diff --git a/apps/sandbox/scripts/sync-source-owned-skins.ts b/apps/sandbox/scripts/sync-source-owned-skins.ts new file mode 100644 index 0000000000..76b98aa64d --- /dev/null +++ b/apps/sandbox/scripts/sync-source-owned-skins.ts @@ -0,0 +1,191 @@ +/** Install the real hosted registry skins consumed by the Sandbox with the stock Shadcn CLI. */ + +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; + +import { isString } from '@videojs/utils/predicate'; + +const projectDir = resolve(import.meta.dirname, '..'); +const workspaceDir = resolve(projectDir, '../..'); +const registryDir = resolve(workspaceDir, 'packages/skins/dist/registry/r'); +const require = createRequire(import.meta.url); +const shadcnBin = require.resolve('shadcn'); +const generatedDir = resolve(projectDir, 'app/_generated'); +const presets = ['video', 'audio', 'live-video', 'live-audio'] as const; +const variants = ['', '-minimal'] as const; + +const localRegistry = existsSync(resolve(registryDir, 'react/registry.json')); +const server = localRegistry ? createServer() : undefined; +const address = server + ? await listen(server) + : (process.env.VIDEOJS_REGISTRY_URL ?? 'https://shadcn.videojs.org/r').replace(/\/$/, ''); + +if (server) { + server.on('request', async (request, response) => { + const path = new URL(request.url ?? '/', address).pathname.slice(1); + const source = await readFile(resolve(registryDir, path)).catch(() => undefined); + + response.setHeader('access-control-allow-origin', '*'); + response.setHeader('cache-control', 'no-store'); + + if (!source) { + response.statusCode = 404; + response.end('Not found.'); + return; + } + + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.end(source); + }); +} + +await rm(generatedDir, { recursive: true, force: true }); + +try { + await installFramework('react', resolve(generatedDir, 'components/videojs'), address); + await installFramework('html', resolve(generatedDir, 'html/components/videojs'), address); +} finally { + if (server) await close(server); +} + +await runCommand('git', ['check-ignore', '--quiet', 'apps/sandbox/app/_generated'], workspaceDir); + +console.log('Installed 8 React and 8 HTML source-owned Sandbox skins from the local hosted registry.'); + +async function installFramework(framework: 'react' | 'html', destination: string, address: string): Promise { + const root = await mkdtemp(resolve(tmpdir(), `videojs-sandbox-${framework}-`)); + + try { + await writeFixture(root, `${address}/${framework}`); + + const items = presets.flatMap((preset) => variants.map((variant) => `@videojs/${preset}${variant}`)); + + await runCommand(process.execPath, [shadcnBin, 'add', ...items, '--cwd', root, '--yes', '--silent'], root); + + await mkdir(destination, { recursive: true }); + await cp(resolve(root, 'src/components/videojs'), destination, { recursive: true }); + + if (framework === 'react') { + await cp(resolve(root, 'src/lib'), resolve(destination, '../../lib'), { recursive: true }); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function writeFixture(root: string, address: string): Promise { + const packageJson = { + name: 'videojs-sandbox-skins', + private: true, + type: 'module', + packageManager: 'pnpm@11.17.0', + dependencies: { + '@videojs/core': '*', + '@videojs/html': '10.0.0-beta.32', + '@videojs/react': '10.0.0-beta.32', + clsx: '*', + react: '*', + 'tailwind-merge': '*', + }, + }; + const components = { + $schema: 'https://ui.shadcn.com/schema.json', + style: 'new-york', + rsc: false, + tsx: true, + tailwind: { + config: '', + css: 'src/index.css', + baseColor: 'neutral', + cssVariables: true, + prefix: '', + }, + aliases: { + components: '@/components', + ui: '@/components/ui', + utils: '@/lib/utils', + lib: '@/lib', + hooks: '@/hooks', + }, + registries: { + '@videojs': `${address}/{name}.json`, + }, + }; + const tsconfig = { + compilerOptions: { + jsx: 'react-jsx', + module: 'ESNext', + moduleResolution: 'Bundler', + noEmit: true, + paths: { '@/*': ['./src/*'] }, + skipLibCheck: true, + strict: true, + target: 'ES2022', + }, + include: ['src'], + }; + + await mkdir(resolve(root, 'src/lib'), { recursive: true }); + await writeFile(resolve(root, 'package.json'), `${JSON.stringify(packageJson, null, 2)}\n`); + await writeFile(resolve(root, 'components.json'), `${JSON.stringify(components, null, 2)}\n`); + await writeFile(resolve(root, 'tsconfig.json'), `${JSON.stringify(tsconfig, null, 2)}\n`); + await writeFile(resolve(root, 'src/index.css'), '@import "./components/videojs/styles/theme.css";\n'); + await writeFile( + resolve(root, 'src/lib/utils.ts'), + `import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n` + ); +} + +async function runCommand( + executable: string, + args: readonly string[], + cwd: string +): Promise<{ readonly stdout: string; readonly stderr: string }> { + return new Promise((resolvePromise, reject) => { + execFile( + executable, + [...args], + { + cwd, + encoding: 'utf8', + env: { ...process.env, CI: '1', FORCE_COLOR: '0', NO_COLOR: '1' }, + maxBuffer: 100 * 1024 * 1024, + }, + (error, stdout, stderr) => { + if (!error) { + resolvePromise({ stdout, stderr }); + return; + } + + reject( + new Error([`${executable} ${args.join(' ')}`, stdout, stderr].filter(Boolean).join('\n'), { + cause: error, + }) + ); + } + ); + }); +} + +async function listen(server: Server): Promise { + await new Promise((resolvePromise, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolvePromise); + }); + + const address = server.address(); + if (!address || isString(address)) throw new Error('Could not resolve the registry server address.'); + + return `http://127.0.0.1:${address.port}`; +} + +async function close(server: Server): Promise { + await new Promise((resolvePromise, reject) => { + server.close((error) => (error ? reject(error) : resolvePromise())); + }); +} diff --git a/apps/sandbox/templates/cdn/main.ts b/apps/sandbox/templates/cdn/main.ts index a3850840ca..6728789234 100644 --- a/apps/sandbox/templates/cdn/main.ts +++ b/apps/sandbox/templates/cdn/main.ts @@ -2,7 +2,7 @@ import '@app/styles.css'; import { EMBED_PRESETS } from '@app/constants'; import { renderChapters } from '@app/shared/html/chapters'; import { createHtmlSandboxState, createLatestLoader, renderMediaAttrs } from '@app/shared/html/sandbox-state'; -import { CSS_SKIN_TAGS, LIVE_VIDEO_CSS_SKIN_TAGS } from '@app/shared/html/skin-tags'; +import { CSS_SKIN_TAGS, LIVE_AUDIO_CSS_SKIN_TAGS, LIVE_VIDEO_CSS_SKIN_TAGS } from '@app/shared/html/skin-tags'; import { renderStoryboard } from '@app/shared/html/storyboard'; import { loadAudioStylesheets, loadVideoStylesheets } from '@app/shared/html/stylesheets'; import { ensureCdnSandboxLocale } from '@app/shared/i18n/cdn-sandbox-locales'; @@ -177,8 +177,13 @@ async function loadCdnPreset(preset: Preset, skin: Skin, live: boolean) { case 'mux-audio-spf': case 'hls-audio': case 'spotify-audio': - if (skin === 'minimal') await import('@videojs/html/cdn/audio-minimal'); - else await import('@videojs/html/cdn/audio'); + if (live) { + if (skin === 'minimal') await import('@videojs/html/cdn/live-audio-minimal'); + else await import('@videojs/html/cdn/live-audio'); + } else { + if (skin === 'minimal') await import('@videojs/html/cdn/audio-minimal'); + else await import('@videojs/html/cdn/audio'); + } break; case 'background-video': @@ -316,7 +321,7 @@ function getPlayerTag(preset: Preset, live: boolean): string { function getSkinTag(preset: Preset, skin: Skin, live: boolean): string { if (isBackgroundPreset(preset)) return 'background-video-skin'; - if (isAudioPreset(preset)) return CSS_SKIN_TAGS[skin].audio; + if (isAudioPreset(preset)) return live ? LIVE_AUDIO_CSS_SKIN_TAGS[skin] : CSS_SKIN_TAGS[skin].audio; if (live) return LIVE_VIDEO_CSS_SKIN_TAGS[skin]; @@ -351,9 +356,9 @@ function getMediaTag(preset: Preset): string { return tags[preset] ?? 'video'; } -function loadStylesheets(preset: Preset, skin: Skin) { - if (isAudioPreset(preset)) loadAudioStylesheets(skin); - else if (!isBackgroundPreset(preset)) loadVideoStylesheets(skin); +function loadStylesheets(preset: Preset, skin: Skin, live: boolean) { + if (isAudioPreset(preset)) loadAudioStylesheets(skin, live); + else if (!isBackgroundPreset(preset)) loadVideoStylesheets(skin, live); // Background CSS is loaded via dynamic import in loadCdnPreset. } @@ -376,7 +381,10 @@ function canPlayLive(preset: Preset): boolean { preset === 'mux-video' || preset === 'mux-video-spf' || preset === 'native-hls-video' || - preset === 'hls-video' + preset === 'hls-video' || + preset === 'mux-audio' || + preset === 'mux-audio-spf' || + preset === 'hls-audio' ); } @@ -393,7 +401,7 @@ async function render() { // Load the locale before rendering, but outside loadLatest so locale errors keep their specific message. await ensureCdnSandboxLocale(locale); - loadStylesheets(preset, state.skin); + loadStylesheets(preset, state.skin, live); const root = document.getElementById('root')!; const playerTag = getPlayerTag(preset, live); diff --git a/apps/sandbox/templates/html-hls-audio/main.ts b/apps/sandbox/templates/html-hls-audio/main.ts index cb846b5518..4df547e368 100644 --- a/apps/sandbox/templates/html-hls-audio/main.ts +++ b/apps/sandbox/templates/html-hls-audio/main.ts @@ -1,5 +1,6 @@ import '@app/styles.css'; import '@videojs/html/audio/player'; +import '@videojs/html/live-audio/player'; import '@videojs/html/media/hls-audio'; import { createHtmlSandboxState, createLatestLoader, renderMediaAttrs } from '@app/shared/html/sandbox-state'; import { loadAudioSkinTag } from '@app/shared/html/skins'; @@ -11,7 +12,7 @@ import { onSkinChange, onSourceChange, } from '@app/shared/sandbox-listener'; -import { SOURCES } from '@app/shared/sources'; +import { isLiveSource, SOURCES } from '@app/shared/sources'; const html = String.raw; @@ -19,18 +20,20 @@ const state = createHtmlSandboxState(); const loadLatest = createLatestLoader(); async function render() { - const tag = await loadLatest(() => loadAudioSkinTag(state.skin, state.styling)); + const live = isLiveSource(state.source); + const tag = await loadLatest(() => loadAudioSkinTag(state.skin, state.styling, { live })); if (!tag) return; const mediaAttrs = renderMediaAttrs(state); + const playerTag = live ? 'live-audio-player' : 'audio-player'; document.getElementById('root')!.innerHTML = html`
- + <${playerTag}> <${tag}> - +
`; } diff --git a/apps/sandbox/templates/html-mux-audio-spf/main.ts b/apps/sandbox/templates/html-mux-audio-spf/main.ts index 6ece9d8985..3290b5cd97 100644 --- a/apps/sandbox/templates/html-mux-audio-spf/main.ts +++ b/apps/sandbox/templates/html-mux-audio-spf/main.ts @@ -1,6 +1,7 @@ import '@app/styles.css'; import { bindSandboxHtmlLocaleChange, prepareSandboxHtmlLocale, wrapSandboxHtmlI18n } from '@app/shared/html/i18n'; import '@videojs/html/audio/player'; +import '@videojs/html/live-audio/player'; import '@videojs/html/media/google-cast'; import '@videojs/html/media/mux-audio/spf'; import '@videojs/html/media/mux-data'; @@ -14,7 +15,7 @@ import { onSkinChange, onSourceChange, } from '@app/shared/sandbox-listener'; -import { SOURCES } from '@app/shared/sources'; +import { isLiveSource, SOURCES } from '@app/shared/sources'; // The SPF-backed ``, alongside the hls.js-backed one in // `html-mux-audio`. Same element behavior over the SPF *audio-only* engine, so @@ -35,10 +36,12 @@ const loadLatest = createLatestLoader(); async function render() { await prepareSandboxHtmlLocale(); - const tag = await loadLatest(() => loadAudioSkinTag(state.skin, state.styling)); + const live = isLiveSource(state.source); + const tag = await loadLatest(() => loadAudioSkinTag(state.skin, state.styling, { live })); if (!tag) return; const mediaAttrs = renderMediaAttrs(state); + const playerTag = live ? 'live-audio-player' : 'audio-player'; // A source carrying signed tokens has no room in the `src` attribute, so it is // assigned as an object below instead. @@ -47,7 +50,7 @@ async function render() { document.getElementById('root')!.innerHTML = wrapSandboxHtmlI18n(html`
- + <${playerTag}> <${tag}> - +
`); diff --git a/apps/sandbox/templates/react-hls-audio/main.tsx b/apps/sandbox/templates/react-hls-audio/main.tsx index 721aa6803f..f1d1470a64 100644 --- a/apps/sandbox/templates/react-hls-audio/main.tsx +++ b/apps/sandbox/templates/react-hls-audio/main.tsx @@ -1,5 +1,5 @@ import '@app/styles.css'; -import { AudioPlayer } from '@app/shared/react/players'; +import { AudioPlayer, LiveAudioPlayer } from '@app/shared/react/players'; import { AudioSkinComponent } from '@app/shared/react/skins'; import { useAutoplay } from '@app/shared/react/use-autoplay'; import { useLoop } from '@app/shared/react/use-loop'; @@ -7,7 +7,7 @@ import { useMuted } from '@app/shared/react/use-muted'; import { usePreload } from '@app/shared/react/use-preload'; import { useSkin } from '@app/shared/react/use-skin'; import { useSource } from '@app/shared/react/use-source'; -import { SOURCES } from '@app/shared/sources'; +import { isLiveSource, SOURCES } from '@app/shared/sources'; import type { Styling } from '@app/types'; import { HlsAudio } from '@videojs/react/media/hls-audio'; import { useMemo } from 'react'; @@ -20,15 +20,17 @@ function readStyling(): Styling { function App() { const skin = useSkin(); const source = useSource(); - const styling = useMemo(readStyling, []); + const styling = useMemo(() => readStyling(), []); const autoplay = useAutoplay(); const muted = useMuted(); const loop = useLoop(); const preload = usePreload(); + const live = isLiveSource(source); + const Player = live ? LiveAudioPlayer : AudioPlayer; return ( - - + + - + ); } diff --git a/apps/sandbox/templates/react-mux-audio-spf/main.tsx b/apps/sandbox/templates/react-mux-audio-spf/main.tsx index 78ff93ee00..016e08d3b5 100644 --- a/apps/sandbox/templates/react-mux-audio-spf/main.tsx +++ b/apps/sandbox/templates/react-mux-audio-spf/main.tsx @@ -1,5 +1,5 @@ import '@app/styles.css'; -import { AudioPlayer } from '@app/shared/react/players'; +import { AudioPlayer, LiveAudioPlayer } from '@app/shared/react/players'; import { SandboxI18nProvider } from '@app/shared/react/sandbox-i18n'; import { AudioSkinComponent } from '@app/shared/react/skins'; import { useAutoplay } from '@app/shared/react/use-autoplay'; @@ -8,7 +8,7 @@ import { useMuted } from '@app/shared/react/use-muted'; import { usePreload } from '@app/shared/react/use-preload'; import { useSkin } from '@app/shared/react/use-skin'; import { useSource } from '@app/shared/react/use-source'; -import { SOURCES } from '@app/shared/sources'; +import { isLiveSource, SOURCES } from '@app/shared/sources'; import type { Styling } from '@app/types'; import { GoogleCast } from '@videojs/react/media/google-cast'; import { MuxAudio } from '@videojs/react/media/mux-audio/spf'; @@ -28,11 +28,13 @@ function readStyling(): Styling { function App() { const skin = useSkin(); const source = useSource(); - const styling = useMemo(readStyling, []); + const styling = useMemo(() => readStyling(), []); const autoplay = useAutoplay(); const muted = useMuted(); const loop = useLoop(); const preload = usePreload(); + const live = isLiveSource(source); + const Player = live ? LiveAudioPlayer : AudioPlayer; // A source carrying signed tokens has no room in a plain `src`. A `drm.token` // is accepted but inert on this flavor. @@ -40,8 +42,8 @@ function App() { return ( - - + + - + ); } diff --git a/apps/sandbox/templates/react-mux-audio/main.tsx b/apps/sandbox/templates/react-mux-audio/main.tsx index 9c37141f46..0433c8b69b 100644 --- a/apps/sandbox/templates/react-mux-audio/main.tsx +++ b/apps/sandbox/templates/react-mux-audio/main.tsx @@ -1,5 +1,5 @@ import '@app/styles.css'; -import { AudioPlayer } from '@app/shared/react/players'; +import { AudioPlayer, LiveAudioPlayer } from '@app/shared/react/players'; import { SandboxI18nProvider } from '@app/shared/react/sandbox-i18n'; import { AudioSkinComponent } from '@app/shared/react/skins'; import { useAutoplay } from '@app/shared/react/use-autoplay'; @@ -8,7 +8,7 @@ import { useMuted } from '@app/shared/react/use-muted'; import { usePreload } from '@app/shared/react/use-preload'; import { useSkin } from '@app/shared/react/use-skin'; import { useSource } from '@app/shared/react/use-source'; -import { SOURCES } from '@app/shared/sources'; +import { isLiveSource, SOURCES } from '@app/shared/sources'; import type { Styling } from '@app/types'; import { GoogleCast } from '@videojs/react/media/google-cast'; import { MuxAudio } from '@videojs/react/media/mux-audio'; @@ -23,11 +23,13 @@ function readStyling(): Styling { function App() { const skin = useSkin(); const source = useSource(); - const styling = useMemo(readStyling, []); + const styling = useMemo(() => readStyling(), []); const autoplay = useAutoplay(); const muted = useMuted(); const loop = useLoop(); const preload = usePreload(); + const live = isLiveSource(source); + const Player = live ? LiveAudioPlayer : AudioPlayer; // A source carrying signed tokens has no room in a plain `src`, so it is passed // structured instead — the same way `react-mux-video` does. @@ -35,8 +37,8 @@ function App() { return ( - - + + - + ); } diff --git a/apps/sandbox/vite.config.ts b/apps/sandbox/vite.config.ts index f63458db49..a00b33710d 100644 --- a/apps/sandbox/vite.config.ts +++ b/apps/sandbox/vite.config.ts @@ -19,6 +19,7 @@ const htmlCdnSourceI18n = `${htmlPackageDir}/src/cdn/i18n.ts`; const cdnSandboxMainSrc = resolve(__dirname, 'src/cdn/main.ts'); const cdnSandboxMainTemplate = resolve(__dirname, 'templates/cdn/main.ts'); +const hasWorkspaceSkins = existsSync(resolve(__dirname, '../../packages/skins/package.json')); /** True when the importer is one of the prebuilt @videojs/html CDN chunks. */ function isHtmlCdnChunk(importer?: string): boolean { @@ -161,11 +162,18 @@ export default defineConfig({ }, setup: { command: 'tsx scripts/setup.ts', - dependsOn: ['@videojs/core#build'], + dependsOn: ['@videojs/core#build', ...(hasWorkspaceSkins ? ['@videojs/skins#build:shadcn'] : [])], // Setup deterministically mirrors tracked templates into the gitignored // scratch tree. Keep that generated tree out of its own fingerprint. - input: ['scripts/setup.ts', 'scripts/shared.ts', 'scripts/generate-cdn-locale-loaders.ts', 'templates/**'], - output: ['src/**', 'app/shared/i18n/cdn-locale-loaders.generated.ts'], + input: [ + 'scripts/setup.ts', + 'scripts/shared.ts', + 'scripts/generate-cdn-locale-loaders.ts', + 'scripts/sync-source-owned-skins.ts', + 'templates/**', + { pattern: 'packages/skins/dist/registry/r/**', base: 'workspace' }, + ], + output: ['src/**', 'app/_generated/**', 'app/shared/i18n/cdn-locale-loaders.generated.ts'], }, build: { command: 'vp build', @@ -183,24 +191,30 @@ export default defineConfig({ plugins: [sandboxTemplateSyncPlugin(), cdnSandboxI18nPlugin(), tailwindcss(), react(), serveAppShell()], resolve: { alias: { + '@': resolve(__dirname, 'app/_generated'), '@app': resolve(__dirname, 'app'), '@videojs/html/cdn/i18n': htmlCdnI18nRegistry, ...(existsSync(cdnSandboxMainTemplate) ? { [cdnSandboxMainSrc]: cdnSandboxMainTemplate } : {}), }, conditions: ['development', 'import', 'module', 'browser', 'default'], - dedupe: [ - '@videojs/core', - '@videojs/html', - '@videojs/icons', - '@videojs/skins', - '@videojs/utils', + dedupe: ['@videojs/core', '@videojs/html', '@videojs/icons', '@videojs/utils', 'react', 'react-dom'], + }, + optimizeDeps: { + // The Sandbox can load every media adapter and generated React skin. Prebundle their runtime dependencies before + // serving so discovering a new route cannot hot-reload an already mounted player graph during development or E2E. + include: [ + '@videojs/html > @videojs/element > @lit/context', + '@videojs/media > dashjs', + '@videojs/media > hls.js', + '@videojs/media > mux-embed', 'react', 'react-dom', + 'react-dom/client', + 'react/jsx-dev-runtime', + 'react/jsx-runtime', ], - }, - optimizeDeps: { - include: ['@videojs/html > @videojs/element > @lit/context', 'react', 'react-dom'], exclude: ['@videojs/core', '@videojs/html', '@videojs/react', '@videojs/spf', '@videojs/store', '@videojs/utils'], + noDiscovery: true, }, server: { port: 5173, diff --git a/packages/skins/dev/controls.ts b/packages/skins/dev/controls.ts new file mode 100644 index 0000000000..46508cdabe --- /dev/null +++ b/packages/skins/dev/controls.ts @@ -0,0 +1,188 @@ +import { SOURCES } from '../../../apps/sandbox/app/shared/sources'; +import { errorSource, mediaIds, previewWidth, type PreviewOptions } from './options'; + +export interface PreviewControls { + readonly options: HTMLFormElement; + readonly width: HTMLElement; +} + +export function createPreviewControls( + preview: PreviewOptions, + setPlayerWidth: (width: number) => void +): PreviewControls { + return { + options: createOptions(preview), + width: createWidthControl(preview.playerWidth, setPlayerWidth), + }; +} + +function createOptions(preview: PreviewOptions): HTMLFormElement { + const form = document.createElement('form'); + + form.className = 'preview-controls'; + form.ariaLabel = 'Skin preview options'; + form.append( + createSelect('framework', 'Framework', preview.framework, [ + ['react', 'React'], + ['html', 'HTML'], + ]), + createSelect('skin', 'Skin', preview.skin, [ + ['default-video', 'Default Video'], + ['minimal-video', 'Minimal Video'], + ['default-live-video', 'Default Live Video'], + ['minimal-live-video', 'Minimal Live Video'], + ['default-live-audio', 'Default Live Audio'], + ['minimal-live-audio', 'Minimal Live Audio'], + ['default-audio', 'Default Audio'], + ['minimal-audio', 'Minimal Audio'], + ]), + createSelect('style', 'Styling', preview.styleMode, [ + ['css', 'CSS'], + ['tailwind', 'Tailwind'], + ]), + createSelect( + 'media', + 'Media', + preview.mediaId, + mediaIds.map((id) => [id, id === 'error' ? errorSource.label : SOURCES[id].label]) + ), + createSelect('captions', 'Captions', preview.captionsMode, [ + ['single', 'Single track'], + ['multiple', 'Multiple tracks'], + ]), + createCopyButton(preview) + ); + form.addEventListener('change', (event) => { + if (!(event.target instanceof HTMLSelectElement)) return; + + const next = new URLSearchParams(location.search); + + next.set(event.target.name, event.target.value); + location.search = next.toString(); + }); + + return form; +} + +function createWidthControl(initialWidth: number, setPlayerWidth: (width: number) => void): HTMLElement { + const section = document.createElement('section'); + const header = document.createElement('div'); + const label = document.createElement('label'); + const output = document.createElement('output'); + const range = document.createElement('input'); + const ticks = document.createElement('datalist'); + const presets = document.createElement('div'); + + section.className = 'preview-width'; + section.ariaLabel = 'Player width'; + header.className = 'preview-width-header'; + label.htmlFor = 'preview-player-width'; + label.textContent = 'Player width'; + output.htmlFor = 'preview-player-width'; + range.id = 'preview-player-width'; + range.className = 'preview-width-range'; + range.type = 'range'; + range.min = String(previewWidth.min); + range.max = String(previewWidth.max); + range.step = '1'; + range.value = String(initialWidth); + range.setAttribute('list', 'preview-player-breakpoints'); + ticks.id = 'preview-player-breakpoints'; + ticks.append(...previewWidth.presets.map(({ value }) => new Option('', String(value)))); + presets.className = 'preview-width-presets'; + + const buttons = previewWidth.presets.map(({ label: text, value }) => { + const button = document.createElement('button'); + + button.type = 'button'; + button.textContent = `${text} · ${value}px`; + button.addEventListener('click', () => update(value)); + presets.append(button); + return { button, value }; + }); + const update = (value: number) => { + const width = Math.min(previewWidth.max, Math.max(previewWidth.min, value)); + const next = new URLSearchParams(location.search); + + range.value = String(width); + output.value = `${width}px · ${formatRem(width)}`; + setPlayerWidth(width); + + for (const preset of buttons) preset.button.ariaPressed = String(preset.value === width); + + next.set('width', String(width)); + history.replaceState(null, '', `${location.pathname}?${next}${location.hash}`); + }; + + range.addEventListener('input', () => update(range.valueAsNumber)); + header.append(label, output); + section.append(header, range, ticks, presets); + update(initialWidth); + + return section; +} + +function createCopyButton(preview: PreviewOptions): HTMLButtonElement { + const button = document.createElement('button'); + + button.className = 'preview-copy'; + button.type = 'button'; + button.textContent = 'Copy details'; + button.addEventListener('click', async () => { + const width = getPlayerWidth(); + const details = [ + 'Video.js skins preview', + `URL: ${location.href}`, + `framework=${preview.framework}`, + `skin=${preview.skin}`, + `style=${preview.styleMode}`, + `media=${preview.mediaId} (${preview.media.label})`, + `captions=${preview.captionsMode}`, + `width=${width}px (${formatRem(width)})`, + ].join('\n'); + + try { + await navigator.clipboard.writeText(details); + button.textContent = 'Copied'; + } catch { + button.textContent = 'Copy failed'; + } + + setTimeout(() => { + button.textContent = 'Copy details'; + }, 3000); + }); + + return button; +} + +function createSelect( + name: string, + labelText: string, + value: string, + options: readonly (readonly [value: string, label: string])[] +): HTMLLabelElement { + const label = document.createElement('label'); + const text = document.createElement('span'); + const select = document.createElement('select'); + + label.className = `preview-control preview-control-${name}`; + text.textContent = labelText; + select.name = name; + select.append( + ...options.map(([optionValue, optionLabel]) => new Option(optionLabel, optionValue, false, optionValue === value)) + ); + label.append(text, select); + + return label; +} + +function getPlayerWidth(): number { + const root = document.getElementById('root'); + + return Number.parseInt(root?.style.getPropertyValue('--preview-player-width') ?? '', 10) || previewWidth.default; +} + +function formatRem(width: number): string { + return `${Math.round((width / 16) * 100) / 100}rem`; +} diff --git a/packages/skins/dev/html-media.ts b/packages/skins/dev/html-media.ts new file mode 100644 index 0000000000..da93c25126 --- /dev/null +++ b/packages/skins/dev/html-media.ts @@ -0,0 +1,76 @@ +import type { SandboxSource } from '../../../apps/sandbox/app/shared/sources'; +import type { CaptionsMode } from './options'; + +export interface HtmlPreviewMediaOptions { + readonly captions: string; + readonly captionsMode: CaptionsMode; + readonly isAudio: boolean; + readonly media: SandboxSource; + readonly storyboard: string | undefined; +} + +export async function defineHtmlMedia({ isAudio, media }: HtmlPreviewMediaOptions): Promise { + if (isAudio && (media.source || media.type === 'hls')) { + await import('../../html/src/define/media/mux-audio/hls-js'); + return; + } + + if (media.source || media.type === 'hls') { + await import('../../html/src/define/media/mux-video/hls-js'); + } else if (media.type === 'dash') { + await import('../../html/src/define/media/dash-video'); + } +} + +export function renderHtmlMedia({ + captions, + captionsMode, + isAudio, + media, + storyboard, +}: HtmlPreviewMediaOptions): string { + const tag = isAudio + ? media.source || media.type === 'hls' + ? 'mux-audio' + : 'audio' + : media.source + ? 'mux-video' + : media.type === 'dash' + ? 'dash-video' + : media.type === 'hls' + ? 'mux-video' + : 'video'; + const sourceAttribute = media.source || !media.url ? '' : ` src="${escapeAttribute(media.url)}"`; + const chapterTracks = + media.chapters + ?.map( + ({ isDefault, label, lang, src }) => + `` + ) + .join('') ?? ''; + const storyboardTrack = storyboard + ? `` + : ''; + const videoTracks = isAudio + ? '' + : `${ + captionsMode === 'multiple' + ? `` + : '' + }${storyboardTrack}`; + + return `<${tag} id="preview-media"${sourceAttribute} playsinline crossorigin="anonymous">${videoTracks}${chapterTracks}`; +} + +export function assignHtmlMediaSource(root: ParentNode, source: SandboxSource['source']): void { + if (!source) return; + + const element = root.querySelector }>('#preview-media'); + if (!element) throw new Error('Expected the structured-source media element to exist.'); + + element.source = source; +} + +function escapeAttribute(value: string): string { + return value.replaceAll('&', '&').replaceAll('"', '"'); +} diff --git a/packages/skins/dev/loaders.ts b/packages/skins/dev/loaders.ts new file mode 100644 index 0000000000..f601784b89 --- /dev/null +++ b/packages/skins/dev/loaders.ts @@ -0,0 +1,99 @@ +import type { PreviewOptions } from './options'; + +export type HtmlPreviewSkin = (props?: { className?: string }) => { toString(): string }; +export type ReactPreviewSkin = React.ComponentType>; +export type PreviewSkin = HtmlPreviewSkin | ReactPreviewSkin; + +const modules = { + 'react/default-video/css': () => + import('../vjsc/skins/default-video/skin.tsx?style=css&target=react&skin=default-video'), + 'react/default-video/tailwind': () => + import('../vjsc/skins/default-video/skin.tsx?style=tailwind&target=react&skin=default-video'), + 'react/minimal-video/css': () => + import('../vjsc/skins/minimal-video/skin.tsx?style=css&target=react&skin=minimal-video'), + 'react/minimal-video/tailwind': () => + import('../vjsc/skins/minimal-video/skin.tsx?style=tailwind&target=react&skin=minimal-video'), + 'react/default-live-video/css': () => + import('../vjsc/skins/default-live-video/skin.tsx?style=css&target=react&skin=default-live-video'), + 'react/default-live-video/tailwind': () => + import('../vjsc/skins/default-live-video/skin.tsx?style=tailwind&target=react&skin=default-live-video'), + 'react/minimal-live-video/css': () => + import('../vjsc/skins/minimal-live-video/skin.tsx?style=css&target=react&skin=minimal-live-video'), + 'react/minimal-live-video/tailwind': () => + import('../vjsc/skins/minimal-live-video/skin.tsx?style=tailwind&target=react&skin=minimal-live-video'), + 'react/default-live-audio/css': () => + import('../vjsc/skins/default-live-audio/skin.tsx?style=css&target=react&skin=default-live-audio'), + 'react/default-live-audio/tailwind': () => + import('../vjsc/skins/default-live-audio/skin.tsx?style=tailwind&target=react&skin=default-live-audio'), + 'react/minimal-live-audio/css': () => + import('../vjsc/skins/minimal-live-audio/skin.tsx?style=css&target=react&skin=minimal-live-audio'), + 'react/minimal-live-audio/tailwind': () => + import('../vjsc/skins/minimal-live-audio/skin.tsx?style=tailwind&target=react&skin=minimal-live-audio'), + 'react/default-audio/css': () => + import('../vjsc/skins/default-audio/skin.tsx?style=css&target=react&skin=default-audio'), + 'react/default-audio/tailwind': () => + import('../vjsc/skins/default-audio/skin.tsx?style=tailwind&target=react&skin=default-audio'), + 'react/minimal-audio/css': () => + import('../vjsc/skins/minimal-audio/skin.tsx?style=css&target=react&skin=minimal-audio'), + 'react/minimal-audio/tailwind': () => + import('../vjsc/skins/minimal-audio/skin.tsx?style=tailwind&target=react&skin=minimal-audio'), + 'html/default-video/css': () => + import('../vjsc/skins/default-video/skin.tsx?style=css&target=html&skin=default-video'), + 'html/default-video/tailwind': () => + import('../vjsc/skins/default-video/skin.tsx?style=tailwind&target=html&skin=default-video'), + 'html/minimal-video/css': () => + import('../vjsc/skins/minimal-video/skin.tsx?style=css&target=html&skin=minimal-video'), + 'html/minimal-video/tailwind': () => + import('../vjsc/skins/minimal-video/skin.tsx?style=tailwind&target=html&skin=minimal-video'), + 'html/default-live-video/css': () => + import('../vjsc/skins/default-live-video/skin.tsx?style=css&target=html&skin=default-live-video'), + 'html/default-live-video/tailwind': () => + import('../vjsc/skins/default-live-video/skin.tsx?style=tailwind&target=html&skin=default-live-video'), + 'html/minimal-live-video/css': () => + import('../vjsc/skins/minimal-live-video/skin.tsx?style=css&target=html&skin=minimal-live-video'), + 'html/minimal-live-video/tailwind': () => + import('../vjsc/skins/minimal-live-video/skin.tsx?style=tailwind&target=html&skin=minimal-live-video'), + 'html/default-live-audio/css': () => + import('../vjsc/skins/default-live-audio/skin.tsx?style=css&target=html&skin=default-live-audio'), + 'html/default-live-audio/tailwind': () => + import('../vjsc/skins/default-live-audio/skin.tsx?style=tailwind&target=html&skin=default-live-audio'), + 'html/minimal-live-audio/css': () => + import('../vjsc/skins/minimal-live-audio/skin.tsx?style=css&target=html&skin=minimal-live-audio'), + 'html/minimal-live-audio/tailwind': () => + import('../vjsc/skins/minimal-live-audio/skin.tsx?style=tailwind&target=html&skin=minimal-live-audio'), + 'html/default-audio/css': () => + import('../vjsc/skins/default-audio/skin.tsx?style=css&target=html&skin=default-audio'), + 'html/default-audio/tailwind': () => + import('../vjsc/skins/default-audio/skin.tsx?style=tailwind&target=html&skin=default-audio'), + 'html/minimal-audio/css': () => + import('../vjsc/skins/minimal-audio/skin.tsx?style=css&target=html&skin=minimal-audio'), + 'html/minimal-audio/tailwind': () => + import('../vjsc/skins/minimal-audio/skin.tsx?style=tailwind&target=html&skin=minimal-audio'), +} as const; + +type ModuleKey = keyof typeof modules; + +const skinExports = { + 'default-video': 'DefaultVideoSkin', + 'minimal-video': 'MinimalVideoSkin', + 'default-live-video': 'DefaultLiveVideoSkin', + 'minimal-live-video': 'MinimalLiveVideoSkin', + 'default-live-audio': 'DefaultLiveAudioSkin', + 'minimal-live-audio': 'MinimalLiveAudioSkin', + 'default-audio': 'DefaultAudioSkin', + 'minimal-audio': 'MinimalAudioSkin', +} as const satisfies Record; + +type SkinExport = (typeof skinExports)[keyof typeof skinExports]; +type SkinModule = Partial>; + +/** Load one statically discoverable authored Skin transform. */ +export async function loadSkin(options: PreviewOptions): Promise { + const key: ModuleKey = `${options.framework}/${options.skin}/${options.styleMode}`; + // SAFETY: every static module is transformed to the framework-specific Skin export selected below. + const loaded = (await modules[key]()) as SkinModule; + const Skin = loaded[skinExports[options.skin]]; + if (!Skin) throw new Error(`Skin module \`${key}\` did not export \`${skinExports[options.skin]}\`.`); + + return Skin; +} diff --git a/packages/skins/dev/main.tsx b/packages/skins/dev/main.tsx index d405ec53ba..52a8b972b9 100644 --- a/packages/skins/dev/main.tsx +++ b/packages/skins/dev/main.tsx @@ -1,158 +1,22 @@ import { createRoot } from 'react-dom/client'; -import { - SOURCE_IDS, - SOURCES, - getPosterSrc, - getStoryboardSrc, - type SandboxSource, -} from '../../../apps/sandbox/app/shared/sources'; -import { Audio } from '../../react/src/media/audio'; -import { DashVideo } from '../../react/src/media/dash-video'; -import { MuxAudio } from '../../react/src/media/mux-audio/hls-js'; -import { MuxVideo } from '../../react/src/media/mux-video'; -import { Video } from '../../react/src/media/video'; import { AudioPlayer } from '../../react/src/presets/audio/player'; import { LiveAudioPlayer } from '../../react/src/presets/live-audio/player'; import { LiveVideoPlayer } from '../../react/src/presets/live-video/player'; import { VideoPlayer } from '../../react/src/presets/video/player'; +import { createPreviewControls } from './controls'; +import { assignHtmlMediaSource, defineHtmlMedia, renderHtmlMedia } from './html-media'; +import { loadSkin } from './loaders'; +import { readPreviewOptions } from './options'; +import { ReactPreviewMedia } from './react-media'; import './styles.css'; const captions = new URL('./captions.vtt', import.meta.url).href; +const preview = readPreviewOptions(); +const Skin = await loadSkin(preview); -const previewWidth = { - default: 960, - max: 960, - min: 240, - presets: [ - { label: '24rem', value: 384 }, - { label: '32rem', value: 512 }, - { label: '42rem', value: 672 }, - { label: '60rem', value: 960 }, - ], -} as const; - -const errorSource = { - label: 'Unsupported source (error dialog)', - url: '/missing-video-that-does-not-exist.mp4', - type: 'mp4', -} satisfies SandboxSource; -const mediaIds = [...SOURCE_IDS, 'error'] as const; - -type MediaId = (typeof mediaIds)[number]; -type SkinName = - | 'default-video' - | 'minimal-video' - | 'default-live-video' - | 'minimal-live-video' - | 'default-live-audio' - | 'minimal-live-audio' - | 'default-audio' - | 'minimal-audio'; - -const modules = { - 'react/default-video/css': () => - import('../vjsc/skins/default-video/skin.tsx?style=css&target=react&skin=default-video'), - 'react/default-video/tailwind': () => - import('../vjsc/skins/default-video/skin.tsx?style=tailwind&target=react&skin=default-video'), - 'react/minimal-video/css': () => - import('../vjsc/skins/minimal-video/skin.tsx?style=css&target=react&skin=minimal-video'), - 'react/minimal-video/tailwind': () => - import('../vjsc/skins/minimal-video/skin.tsx?style=tailwind&target=react&skin=minimal-video'), - 'react/default-live-video/css': () => - import('../vjsc/skins/default-live-video/skin.tsx?style=css&target=react&skin=default-live-video'), - 'react/default-live-video/tailwind': () => - import('../vjsc/skins/default-live-video/skin.tsx?style=tailwind&target=react&skin=default-live-video'), - 'react/minimal-live-video/css': () => - import('../vjsc/skins/minimal-live-video/skin.tsx?style=css&target=react&skin=minimal-live-video'), - 'react/minimal-live-video/tailwind': () => - import('../vjsc/skins/minimal-live-video/skin.tsx?style=tailwind&target=react&skin=minimal-live-video'), - 'react/default-live-audio/css': () => - import('../vjsc/skins/default-live-audio/skin.tsx?style=css&target=react&skin=default-live-audio'), - 'react/default-live-audio/tailwind': () => - import('../vjsc/skins/default-live-audio/skin.tsx?style=tailwind&target=react&skin=default-live-audio'), - 'react/minimal-live-audio/css': () => - import('../vjsc/skins/minimal-live-audio/skin.tsx?style=css&target=react&skin=minimal-live-audio'), - 'react/minimal-live-audio/tailwind': () => - import('../vjsc/skins/minimal-live-audio/skin.tsx?style=tailwind&target=react&skin=minimal-live-audio'), - 'react/default-audio/css': () => - import('../vjsc/skins/default-audio/skin.tsx?style=css&target=react&skin=default-audio'), - 'react/default-audio/tailwind': () => - import('../vjsc/skins/default-audio/skin.tsx?style=tailwind&target=react&skin=default-audio'), - 'react/minimal-audio/css': () => - import('../vjsc/skins/minimal-audio/skin.tsx?style=css&target=react&skin=minimal-audio'), - 'react/minimal-audio/tailwind': () => - import('../vjsc/skins/minimal-audio/skin.tsx?style=tailwind&target=react&skin=minimal-audio'), - 'html/default-video/css': () => - import('../vjsc/skins/default-video/skin.tsx?style=css&target=html&skin=default-video'), - 'html/default-video/tailwind': () => - import('../vjsc/skins/default-video/skin.tsx?style=tailwind&target=html&skin=default-video'), - 'html/minimal-video/css': () => - import('../vjsc/skins/minimal-video/skin.tsx?style=css&target=html&skin=minimal-video'), - 'html/minimal-video/tailwind': () => - import('../vjsc/skins/minimal-video/skin.tsx?style=tailwind&target=html&skin=minimal-video'), - 'html/default-live-video/css': () => - import('../vjsc/skins/default-live-video/skin.tsx?style=css&target=html&skin=default-live-video'), - 'html/default-live-video/tailwind': () => - import('../vjsc/skins/default-live-video/skin.tsx?style=tailwind&target=html&skin=default-live-video'), - 'html/minimal-live-video/css': () => - import('../vjsc/skins/minimal-live-video/skin.tsx?style=css&target=html&skin=minimal-live-video'), - 'html/minimal-live-video/tailwind': () => - import('../vjsc/skins/minimal-live-video/skin.tsx?style=tailwind&target=html&skin=minimal-live-video'), - 'html/default-live-audio/css': () => - import('../vjsc/skins/default-live-audio/skin.tsx?style=css&target=html&skin=default-live-audio'), - 'html/default-live-audio/tailwind': () => - import('../vjsc/skins/default-live-audio/skin.tsx?style=tailwind&target=html&skin=default-live-audio'), - 'html/minimal-live-audio/css': () => - import('../vjsc/skins/minimal-live-audio/skin.tsx?style=css&target=html&skin=minimal-live-audio'), - 'html/minimal-live-audio/tailwind': () => - import('../vjsc/skins/minimal-live-audio/skin.tsx?style=tailwind&target=html&skin=minimal-live-audio'), - 'html/default-audio/css': () => - import('../vjsc/skins/default-audio/skin.tsx?style=css&target=html&skin=default-audio'), - 'html/default-audio/tailwind': () => - import('../vjsc/skins/default-audio/skin.tsx?style=tailwind&target=html&skin=default-audio'), - 'html/minimal-audio/css': () => - import('../vjsc/skins/minimal-audio/skin.tsx?style=css&target=html&skin=minimal-audio'), - 'html/minimal-audio/tailwind': () => - import('../vjsc/skins/minimal-audio/skin.tsx?style=tailwind&target=html&skin=minimal-audio'), -} as const; - -type ModuleKey = keyof typeof modules; - -const params = new URLSearchParams(location.search); -const source = params.get('source') === 'legacy' ? 'legacy' : 'vjsc'; -const framework = params.get('framework') === 'html' ? 'html' : 'react'; -const requestedSkin = params.get('skin'); -const skin: SkinName = isSkinName(requestedSkin) ? requestedSkin : 'default-video'; -const isAudio = skin.endsWith('-audio'); -const isLive = skin.includes('-live-'); -const styleMode = source === 'vjsc' && params.get('style') === 'tailwind' ? 'tailwind' : 'css'; -const captionsMode = params.get('captions') === 'multiple' ? 'multiple' : 'single'; -const key: ModuleKey = `${framework}/${skin}/${styleMode}`; -const requestedMedia = params.get('media'); -const mediaId = isMediaId(requestedMedia) ? requestedMedia : isLive ? 'hls-live' : 'mp4-1'; -const requestedWidth = Number.parseInt(params.get('width') ?? '', 10); -const playerWidth = Number.isFinite(requestedWidth) - ? Math.min(previewWidth.max, Math.max(previewWidth.min, requestedWidth)) - : previewWidth.default; -const sourceId = mediaId === 'error' ? null : mediaId; -const media: SandboxSource = sourceId ? SOURCES[sourceId] : errorSource; -const poster = sourceId ? getPosterSrc(sourceId) : undefined; -const storyboard = sourceId ? getStoryboardSrc(sourceId) : undefined; -const loaded = source === 'vjsc' ? await modules[key]() : null; - -if (source === 'vjsc' && styleMode === 'tailwind') await import('../vjsc/styles/tailwind.compiler.css'); - -function App({ Skin }: { Skin: React.ComponentType> }) { - const content = {renderReactMedia()}; - - if (isAudio) return isLive ? {content} : {content}; - - if (isLive) return {content}; - - return {content}; -} +if (preview.styleMode === 'tailwind') await import('../vjsc/styles/tailwind.compiler.css'); type PreviewRoot = HTMLElement & { __videojsSkinsReactRoot?: ReturnType }; @@ -161,539 +25,69 @@ if (!rootElement) throw new Error('Expected the skin preview root to exist.'); const root: PreviewRoot = rootElement; -root.dataset.mediaKind = isAudio ? 'audio' : 'video'; - +root.dataset.mediaKind = preview.isAudio ? 'audio' : 'video'; root.__videojsSkinsReactRoot?.unmount(); delete root.__videojsSkinsReactRoot; -const controls = createPreviewControls(); -const widthControl = createWidthControl(); - -root.before(controls, widthControl); -setPlayerWidth(playerWidth); -if (source === 'legacy') { - await renderLegacy(framework, skin); -} else if (framework === 'react') { - if (!loaded) throw new Error(`VJSC module \`${key}\` was not loaded.`); +const controls = createPreviewControls(preview, (width) => { + root.style.setProperty('--preview-player-width', `${width}px`); +}); - const Skin = getLoadedSkin(loaded); - if (!Skin) throw new Error(`React Skin module \`${key}\` did not export a Skin component.`); +root.before(controls.options, controls.width); +if (preview.framework === 'react') { // SAFETY: VJSC transforms the selected React target into a React component before the module loads. renderReact(>} />); } else { - if (!loaded) throw new Error(`VJSC module \`${key}\` was not loaded.`); - - const Skin = getLoadedSkin(loaded); - if (!Skin) throw new Error(`HTML Skin module \`${key}\` did not export a Skin component.`); - - if (isAudio && isLive) await import('../../html/src/define/live-audio/player'); - else if (isAudio) await import('../../html/src/define/audio/player'); - else if (isLive) await import('../../html/src/define/live-video/player'); + if (preview.isAudio && preview.isLive) await import('../../html/src/define/live-audio/player'); + else if (preview.isAudio) await import('../../html/src/define/audio/player'); + else if (preview.isLive) await import('../../html/src/define/live-video/player'); else await import('../../html/src/define/video/player'); - await defineHtmlMedia(); + const mediaOptions = { ...preview, captions }; + + await defineHtmlMedia(mediaOptions); // SAFETY: VJSC transforms the selected HTML target into a string-rendering function before the module loads. const render = Skin as (props?: { className?: string }) => { toString(): string }; - const posterAttribute = !isAudio && poster ? ` poster="${escapeAttribute(poster)}"` : ''; - const output = String(render({ className: 'preview-player' })).replace('', renderHtmlMedia()); - const playerTag = isAudio - ? isLive + const posterAttribute = preview.poster ? ` poster="${escapeAttribute(preview.poster)}"` : ''; + const output = String(render({ className: 'preview-player' })).replace( + '', + renderHtmlMedia(mediaOptions) + ); + const playerTag = preview.isAudio + ? preview.isLive ? 'live-audio-player' : 'audio-player' - : isLive + : preview.isLive ? 'live-video-player' : 'video-player'; root.innerHTML = `<${playerTag}${posterAttribute}>${output}`; - assignHtmlMediaSource(); + assignHtmlMediaSource(root, preview.media.source); } -async function renderLegacy(framework: 'react' | 'html', skin: SkinName) { - const minimal = skin.startsWith('minimal-'); - - if (framework === 'react') { - if (isAudio) { - await (minimal ? import('../src/minimal/css/audio.css') : import('../src/default/css/audio.css')); - - if (isLive && minimal) { - const { MinimalLiveAudioSkin } = await import('../../react/src/presets/live-audio/minimal-skin'); - - renderReact( - - {renderReactMedia()} - - ); - } else if (isLive) { - const { LiveAudioSkin } = await import('../../react/src/presets/live-audio/skin'); - - renderReact( - - {renderReactMedia()} - - ); - } else if (minimal) { - const { MinimalAudioSkin } = await import('../../react/src/presets/audio/minimal-skin'); - - renderReact( - - {renderReactMedia()} - - ); - } else { - const { AudioSkin } = await import('../../react/src/presets/audio/skin'); - - renderReact( - - {renderReactMedia()} - - ); - } - } else if (isLive) { - await (minimal ? import('../src/minimal/css/video.css') : import('../src/default/css/video.css')); - - if (minimal) { - const { MinimalLiveVideoSkin } = await import('../../react/src/presets/live-video/minimal-skin'); - - renderReact( - - {renderReactMedia()} - - ); - } else { - const { LiveVideoSkin } = await import('../../react/src/presets/live-video/skin'); - - renderReact( - - {renderReactMedia()} - - ); - } - } else { - await (minimal ? import('../src/minimal/css/video.css') : import('../src/default/css/video.css')); - - if (minimal) { - const { MinimalVideoSkin } = await import('../../react/src/presets/video/minimal-skin'); - - renderReact( - - {renderReactMedia()} - - ); - } else { - const { VideoSkin } = await import('../../react/src/presets/video/skin'); - - renderReact( - - {renderReactMedia()} - - ); - } - } - - return; - } - - if (isAudio && isLive) await import('../../html/src/define/live-audio/player'); - else if (isAudio) await import('../../html/src/define/audio/player'); - else if (isLive) await import('../../html/src/define/live-video/player'); - else await import('../../html/src/define/video/player'); - - await defineHtmlMedia(); - - const tag = isAudio - ? isLive - ? minimal - ? 'live-audio-minimal-skin' - : 'live-audio-skin' - : minimal - ? 'audio-minimal-skin' - : 'audio-skin' - : isLive - ? minimal - ? 'live-video-minimal-skin' - : 'live-video-skin' - : minimal - ? 'video-minimal-skin' - : 'video-skin'; - const playerTag = isAudio - ? isLive - ? 'live-audio-player' - : 'audio-player' - : isLive - ? 'live-video-player' - : 'video-player'; - const posterAttribute = !isAudio && poster ? ` poster="${escapeAttribute(poster)}"` : ''; - - if (isAudio) { - if (isLive && minimal) await import('../../html/src/define/live-audio/minimal-skin'); - else if (isLive) await import('../../html/src/define/live-audio/skin'); - else if (minimal) await import('../../html/src/define/audio/minimal-skin'); - else await import('../../html/src/define/audio/skin'); - } else if (isLive && minimal) await import('../../html/src/define/live-video/minimal-skin'); - else if (isLive) await import('../../html/src/define/live-video/skin'); - else if (minimal) await import('../../html/src/define/video/minimal-skin'); - else await import('../../html/src/define/video/skin'); - - root.innerHTML = `<${playerTag}${posterAttribute}><${tag} class="preview-player">${renderHtmlMedia()}`; - assignHtmlMediaSource(); -} - -function renderReactMedia() { - const tracks = ( - <> - - {captionsMode === 'multiple' ? : null} - {media.chapters?.map(({ isDefault, label, lang, src }) => ( - - ))} - {storyboard ? : null} - - ); - - if (isAudio && media.source) { - return ( - - {tracks} - - ); - } - - if (isAudio && media.type === 'hls') { - return ( - - {tracks} - - ); - } - - if (isAudio) { - return ( - - ); - } - - if (media.source) { - return ( - - {tracks} - - ); - } - - if (media.type === 'dash') { - return ( - - {tracks} - - ); - } - - if (media.type === 'hls') { - return ( - - {tracks} - - ); - } - - return ( - +function App({ Skin }: { Skin: React.ComponentType> }) { + const content = ( + + + ); -} - -async function defineHtmlMedia() { - if (isAudio && (media.source || media.type === 'hls')) { - await import('../../html/src/define/media/mux-audio/hls-js'); - return; - } - if (media.source || media.type === 'hls') { - await import('../../html/src/define/media/mux-video/hls-js'); - } else if (media.type === 'dash') { - await import('../../html/src/define/media/dash-video'); + if (preview.isAudio) { + return preview.isLive ? {content} : {content}; } -} - -function renderHtmlMedia() { - const tag = isAudio - ? media.source || media.type === 'hls' - ? 'mux-audio' - : 'audio' - : media.source - ? 'mux-video' - : media.type === 'dash' - ? 'dash-video' - : media.type === 'hls' - ? 'mux-video' - : 'video'; - const sourceAttribute = media.source || !media.url ? '' : ` src="${escapeAttribute(media.url)}"`; - const chapterTracks = - media.chapters - ?.map( - ({ isDefault, label, lang, src }) => - `` - ) - .join('') ?? ''; - const storyboardTrack = storyboard - ? `` - : ''; - - const videoTracks = isAudio - ? '' - : `${ - captionsMode === 'multiple' - ? `` - : '' - }${storyboardTrack}`; - - return `<${tag} id="preview-media"${sourceAttribute} playsinline crossorigin="anonymous">${videoTracks}${chapterTracks}`; -} -function assignHtmlMediaSource() { - if (!media.source) return; + if (preview.isLive) return {content}; - const element = root.querySelector('#preview-media'); - if (!element) throw new Error('Expected the structured-source media element to exist.'); - - element.source = media.source; + return {content}; } -function createPreviewControls() { - const form = document.createElement('form'); - - form.className = 'preview-controls'; - form.ariaLabel = 'Skin preview options'; - form.append( - createSelect('source', 'Source', source, [ - ['vjsc', 'VJSC'], - ['legacy', 'Legacy'], - ]), - createSelect('framework', 'Framework', framework, [ - ['react', 'React'], - ['html', 'HTML'], - ]), - createSelect('skin', 'Skin', skin, [ - ['default-video', 'Default Video'], - ['minimal-video', 'Minimal Video'], - ['default-live-video', 'Default Live Video'], - ['minimal-live-video', 'Minimal Live Video'], - ['default-live-audio', 'Default Live Audio'], - ['minimal-live-audio', 'Minimal Live Audio'], - ['default-audio', 'Default Audio'], - ['minimal-audio', 'Minimal Audio'], - ]), - createSelect( - 'style', - 'Styling', - styleMode, - [ - ['css', 'CSS'], - ['tailwind', 'Tailwind'], - ], - source === 'legacy' - ), - createSelect( - 'media', - 'Media', - mediaId, - mediaIds.map((id) => [id, id === 'error' ? errorSource.label : SOURCES[id].label]) - ), - createSelect('captions', 'Captions', captionsMode, [ - ['single', 'Single track'], - ['multiple', 'Multiple tracks'], - ]), - createCopyButton() - ); - form.addEventListener('change', (event) => { - if (!(event.target instanceof HTMLSelectElement)) return; - - const next = new URLSearchParams(location.search); - - next.set(event.target.name, event.target.value); - - if (event.target.name === 'source' && event.target.value === 'legacy') next.set('style', 'css'); - - location.search = next.toString(); - }); - - return form; -} - -function createWidthControl() { - const section = document.createElement('section'); - const header = document.createElement('div'); - const label = document.createElement('label'); - const output = document.createElement('output'); - const range = document.createElement('input'); - const ticks = document.createElement('datalist'); - const presets = document.createElement('div'); - - section.className = 'preview-width'; - section.ariaLabel = 'Player width'; - header.className = 'preview-width-header'; - label.htmlFor = 'preview-player-width'; - label.textContent = 'Player width'; - output.htmlFor = 'preview-player-width'; - range.id = 'preview-player-width'; - range.className = 'preview-width-range'; - range.type = 'range'; - range.min = String(previewWidth.min); - range.max = String(previewWidth.max); - range.step = '1'; - range.value = String(playerWidth); - range.setAttribute('list', 'preview-player-breakpoints'); - ticks.id = 'preview-player-breakpoints'; - ticks.append(...previewWidth.presets.map(({ value }) => new Option('', String(value)))); - presets.className = 'preview-width-presets'; - - const buttons = previewWidth.presets.map(({ label: text, value }) => { - const button = document.createElement('button'); - - button.type = 'button'; - button.textContent = `${text} · ${value}px`; - button.addEventListener('click', () => update(value)); - presets.append(button); - return { button, value }; - }); - const update = (value: number) => { - const width = Math.min(previewWidth.max, Math.max(previewWidth.min, value)); - const next = new URLSearchParams(location.search); - - range.value = String(width); - output.value = `${width}px · ${formatRem(width)}`; - setPlayerWidth(width); - - for (const preset of buttons) preset.button.ariaPressed = String(preset.value === width); - - next.set('width', String(width)); - history.replaceState(null, '', `${location.pathname}?${next}${location.hash}`); - }; - - range.addEventListener('input', () => update(range.valueAsNumber)); - header.append(label, output); - section.append(header, range, ticks, presets); - update(playerWidth); - - return section; -} - -function createCopyButton() { - const button = document.createElement('button'); - - button.className = 'preview-copy'; - button.type = 'button'; - button.textContent = 'Copy details'; - button.addEventListener('click', async () => { - const details = [ - 'Video.js skins preview', - `URL: ${location.href}`, - `source=${source}`, - `framework=${framework}`, - `skin=${skin}`, - `style=${styleMode}`, - `media=${mediaId} (${media.label})`, - `captions=${captionsMode}`, - `width=${getPlayerWidth()}px (${formatRem(getPlayerWidth())})`, - ].join('\n'); - - try { - await navigator.clipboard.writeText(details); - button.textContent = 'Copied'; - } catch { - button.textContent = 'Copy failed'; - } - - setTimeout(() => { - button.textContent = 'Copy details'; - }, 3000); - }); - - return button; -} - -function createSelect( - name: string, - labelText: string, - value: string, - options: readonly (readonly [value: string, label: string])[], - disabled = false -) { - const label = document.createElement('label'); - const text = document.createElement('span'); - const select = document.createElement('select'); - - label.className = `preview-control preview-control-${name}`; - text.textContent = labelText; - select.name = name; - select.disabled = disabled; - select.append( - ...options.map(([optionValue, optionLabel]) => new Option(optionLabel, optionValue, false, optionValue === value)) - ); - label.append(text, select); - - return label; -} - -function isMediaId(value: string | null): value is MediaId { - return value === 'error' || (value !== null && Object.hasOwn(SOURCES, value)); -} - -function isSkinName(value: string | null): value is SkinName { - return ( - value === 'default-video' || - value === 'minimal-video' || - value === 'default-live-video' || - value === 'minimal-live-video' || - value === 'default-live-audio' || - value === 'minimal-live-audio' || - value === 'default-audio' || - value === 'minimal-audio' - ); -} - -function getLoadedSkin(loaded: Awaited>) { - if ('DefaultVideoSkin' in loaded) return loaded.DefaultVideoSkin; - - if ('MinimalVideoSkin' in loaded) return loaded.MinimalVideoSkin; - - if ('DefaultLiveVideoSkin' in loaded) return loaded.DefaultLiveVideoSkin; - - if ('MinimalLiveVideoSkin' in loaded) return loaded.MinimalLiveVideoSkin; - - if ('DefaultLiveAudioSkin' in loaded) return loaded.DefaultLiveAudioSkin; - - if ('MinimalLiveAudioSkin' in loaded) return loaded.MinimalLiveAudioSkin; - - if ('DefaultAudioSkin' in loaded) return loaded.DefaultAudioSkin; - - if ('MinimalAudioSkin' in loaded) return loaded.MinimalAudioSkin; - - return null; -} - -function escapeAttribute(value: string) { +function escapeAttribute(value: string): string { return value.replaceAll('&', '&').replaceAll('"', '"'); } -function setPlayerWidth(width: number) { - root.style.setProperty('--preview-player-width', `${width}px`); -} - -function getPlayerWidth() { - return Number.parseInt(root.style.getPropertyValue('--preview-player-width'), 10) || previewWidth.default; -} - -function formatRem(width: number) { - return `${Math.round((width / 16) * 100) / 100}rem`; -} - -function renderReact(children: React.ReactNode) { +function renderReact(children: React.ReactNode): void { root.__videojsSkinsReactRoot?.unmount(); const reactRoot = createRoot(root); @@ -704,6 +98,6 @@ function renderReact(children: React.ReactNode) { import.meta.hot?.dispose(() => { root.__videojsSkinsReactRoot?.unmount(); delete root.__videojsSkinsReactRoot; - controls.remove(); - widthControl.remove(); + controls.options.remove(); + controls.width.remove(); }); diff --git a/packages/skins/dev/options.ts b/packages/skins/dev/options.ts new file mode 100644 index 0000000000..8fd38261eb --- /dev/null +++ b/packages/skins/dev/options.ts @@ -0,0 +1,107 @@ +import { + SOURCE_IDS, + SOURCES, + getPosterSrc, + getStoryboardSrc, + type SandboxSource, +} from '../../../apps/sandbox/app/shared/sources'; + +export const previewWidth = { + default: 960, + max: 960, + min: 240, + presets: [ + { label: '24rem', value: 384 }, + { label: '32rem', value: 512 }, + { label: '42rem', value: 672 }, + { label: '60rem', value: 960 }, + ], +} as const; + +export const errorSource = { + label: 'Unsupported source (error dialog)', + url: '/missing-video-that-does-not-exist.mp4', + type: 'mp4', +} satisfies SandboxSource; + +export const mediaIds = [...SOURCE_IDS, 'error'] as const; + +export type CaptionsMode = 'multiple' | 'single'; +export type Framework = 'html' | 'react'; +export type MediaId = (typeof mediaIds)[number]; +export type SkinName = + | 'default-video' + | 'minimal-video' + | 'default-live-video' + | 'minimal-live-video' + | 'default-live-audio' + | 'minimal-live-audio' + | 'default-audio' + | 'minimal-audio'; +export type StyleMode = 'css' | 'tailwind'; + +export interface PreviewOptions { + readonly captionsMode: CaptionsMode; + readonly framework: Framework; + readonly isAudio: boolean; + readonly isLive: boolean; + readonly media: SandboxSource; + readonly mediaId: MediaId; + readonly playerWidth: number; + readonly poster: string | undefined; + readonly skin: SkinName; + readonly sourceId: Exclude | null; + readonly storyboard: string | undefined; + readonly styleMode: StyleMode; +} + +export function readPreviewOptions(search = location.search): PreviewOptions { + const params = new URLSearchParams(search); + const framework = params.get('framework') === 'html' ? 'html' : 'react'; + const requestedSkin = params.get('skin'); + const skin: SkinName = isSkinName(requestedSkin) ? requestedSkin : 'default-video'; + const isAudio = skin.endsWith('-audio'); + const isLive = skin.includes('-live-'); + const styleMode = params.get('style') === 'tailwind' ? 'tailwind' : 'css'; + const captionsMode = params.get('captions') === 'multiple' ? 'multiple' : 'single'; + const requestedMedia = params.get('media'); + const mediaId = isMediaId(requestedMedia) ? requestedMedia : isLive ? 'hls-live' : 'mp4-1'; + const requestedWidth = Number.parseInt(params.get('width') ?? '', 10); + const playerWidth = Number.isFinite(requestedWidth) + ? Math.min(previewWidth.max, Math.max(previewWidth.min, requestedWidth)) + : previewWidth.default; + const sourceId = mediaId === 'error' ? null : mediaId; + const media = sourceId ? SOURCES[sourceId] : errorSource; + + return { + captionsMode, + framework, + isAudio, + isLive, + media, + mediaId, + playerWidth, + poster: sourceId ? getPosterSrc(sourceId) : undefined, + skin, + sourceId, + storyboard: sourceId ? getStoryboardSrc(sourceId) : undefined, + styleMode, + }; +} + +function isMediaId(value: string | null): value is MediaId { + return value === 'error' || (value !== null && Object.hasOwn(SOURCES, value)); +} + +function isSkinName(value: string | null): value is SkinName { + return ( + value === 'default-video' || + value === 'minimal-video' || + value === 'default-live-video' || + value === 'minimal-live-video' || + value === 'default-live-audio' || + value === 'minimal-live-audio' || + value === 'default-audio' || + value === 'minimal-audio' + ); +} diff --git a/packages/skins/dev/react-media.tsx b/packages/skins/dev/react-media.tsx new file mode 100644 index 0000000000..e9e532ad8f --- /dev/null +++ b/packages/skins/dev/react-media.tsx @@ -0,0 +1,82 @@ +import type { SandboxSource } from '../../../apps/sandbox/app/shared/sources'; +import { Audio } from '../../react/src/media/audio'; +import { DashVideo } from '../../react/src/media/dash-video'; +import { MuxAudio } from '../../react/src/media/mux-audio/hls-js'; +import { MuxVideo } from '../../react/src/media/mux-video'; +import { Video } from '../../react/src/media/video'; +import type { CaptionsMode } from './options'; + +export interface ReactPreviewMediaOptions { + readonly captions: string; + readonly captionsMode: CaptionsMode; + readonly isAudio: boolean; + readonly media: SandboxSource; + readonly storyboard: string | undefined; +} + +export function ReactPreviewMedia({ captions, captionsMode, isAudio, media, storyboard }: ReactPreviewMediaOptions) { + const tracks = ( + <> + + {captionsMode === 'multiple' ? : null} + {media.chapters?.map(({ isDefault, label, lang, src }) => ( + + ))} + {storyboard ? : null} + + ); + + if (isAudio && media.source) { + return ( + + {tracks} + + ); + } + + if (isAudio && media.type === 'hls') { + return ( + + {tracks} + + ); + } + + if (isAudio) { + return ( + + ); + } + + if (media.source) { + return ( + + {tracks} + + ); + } + + if (media.type === 'dash') { + return ( + + {tracks} + + ); + } + + if (media.type === 'hls') { + return ( + + {tracks} + + ); + } + + return ( + + ); +} diff --git a/packages/skins/vjsc/styles/tailwind.css b/packages/skins/vjsc/styles/tailwind.css index b001f91ac3..7298f02ec4 100644 --- a/packages/skins/vjsc/styles/tailwind.css +++ b/packages/skins/vjsc/styles/tailwind.css @@ -1,6 +1,6 @@ @layer base, components, utilities; -@import "tailwindcss"; +@import "tailwindcss" source(none); @import "./base.css"; @import "./tailwind.shared.css"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06b1536b0f..2f99764196 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,9 +105,6 @@ importers: '@videojs/react': specifier: workspace:* version: link:../../packages/react - '@videojs/skins': - specifier: workspace:* - version: link:../../packages/skins '@videojs/spf': specifier: workspace:* version: link:../../packages/spf @@ -153,9 +150,6 @@ importers: '@videojs/react': specifier: workspace:* version: link:../../packages/react - '@videojs/skins': - specifier: workspace:* - version: link:../../packages/skins '@videojs/spf': specifier: workspace:* version: link:../../packages/spf @@ -165,6 +159,12 @@ importers: '@videojs/utils': specifier: workspace:* version: link:../../packages/utils + clsx: + specifier: ^2.1.1 + version: 2.1.1 + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 devDependencies: '@tailwindcss/vite': specifier: ^4.3.3 @@ -208,6 +208,9 @@ importers: rimraf: specifier: ^6.1.3 version: 6.1.3 + shadcn: + specifier: ^4.16.2 + version: 4.19.0(supports-color@8.1.1)(typescript@6.0.2) tailwindcss: specifier: ^4.3.3 version: 4.3.3 diff --git a/site/scripts/ejected-skins/templates/html/audio/minimal-skin.tailwind.ts b/site/scripts/ejected-skins/templates/html/audio/minimal-skin.tailwind.ts deleted file mode 100644 index d0cfefa1ff..0000000000 --- a/site/scripts/ejected-skins/templates/html/audio/minimal-skin.tailwind.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { DEFAULT_SEEK_STEP } from '@videojs/html'; -import { renderIcon } from '@videojs/icons/render/minimal'; -import { - button, - buttonGroup, - container, - controls, - dialog, - icon, - iconContainer, - iconFlipped, - iconState, - menu, - playButton, - playbackRate, - popup, - seek, - slider, - time, -} from '@videojs/skins/minimal/tailwind/audio.tailwind'; -import { cn } from '@videojs/utils/style'; - -export function getTemplateHTML() { - return /*html*/ ` - - - - - - - -
- - -
-
- -
-
-
- -
- -
- - - ${renderIcon('spinner', { class: icon })} - - - ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} - ${renderIcon('play', { class: cn(icon, iconState.play.play) })} - ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - - - - - - - - - - ${renderIcon('seek', { class: cn(icon, iconFlipped) })} - ${DEFAULT_SEEK_STEP} - - - - - - - - - - ${renderIcon('seek', { class: icon })} - ${DEFAULT_SEEK_STEP} - - - - - - -
- -
- - - - - - - - - - - - - - - - -
- -
- - ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} - ${renderIcon('volume-low', { class: cn(icon, iconState.mute.volumeLow) })} - ${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - - - - - - - - - - - - - - -
- `; -} diff --git a/site/scripts/ejected-skins/templates/html/audio/skin.tailwind.ts b/site/scripts/ejected-skins/templates/html/audio/skin.tailwind.ts deleted file mode 100644 index f59aa2c5b2..0000000000 --- a/site/scripts/ejected-skins/templates/html/audio/skin.tailwind.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { DEFAULT_SEEK_STEP } from '@videojs/html'; -import { renderIcon } from '@videojs/icons/render'; -import { - button, - buttonGroup, - container, - controls, - dialog, - icon, - iconContainer, - iconFlipped, - iconState, - menu, - playButton, - playbackRate, - popup, - seek, - slider, - time, -} from '@videojs/skins/default/tailwind/audio.tailwind'; -import { cn } from '@videojs/utils/style'; - -export function getTemplateHTML() { - return /*html*/ ` - - - - - - - -
- - -
-
- -
-
-
- -
- -
- - - ${renderIcon('spinner', { class: icon })} - - - ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} - ${renderIcon('play', { class: cn(icon, iconState.play.play) })} - ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - - - - - - - - - - ${renderIcon('seek', { class: cn(icon, iconFlipped) })} - ${DEFAULT_SEEK_STEP} - - - - - - - - - - ${renderIcon('seek', { class: icon })} - ${DEFAULT_SEEK_STEP} - - - - - - -
- -
- - - - - - - - - - - - -
- -
- - - - - - - - - - - - - - - ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} - ${renderIcon('volume-low', { class: cn(icon, iconState.mute.volumeLow) })} - ${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })} - - - - - - - - - - -
-
-
- - - - - - - - - - - - - - - - -
- `; -} diff --git a/site/scripts/ejected-skins/templates/html/live-audio/minimal-skin.tailwind.ts b/site/scripts/ejected-skins/templates/html/live-audio/minimal-skin.tailwind.ts deleted file mode 100644 index d4a1fbb5a4..0000000000 --- a/site/scripts/ejected-skins/templates/html/live-audio/minimal-skin.tailwind.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { renderIcon } from '@videojs/icons/render/minimal'; -import { - button, - buttonGroup, - container, - controls, - dialog, - icon, - iconState, - playButton, - popup, - slider, - spacer, -} from '@videojs/skins/minimal/tailwind/audio.tailwind'; -import { cn } from '@videojs/utils/style'; - -export function getTemplateHTML() { - return /*html*/ ` - - - - - - - -
- - -
-
- -
-
-
- -
- -
- - - ${renderIcon('spinner', { class: icon })} - - - ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} - ${renderIcon('play', { class: cn(icon, iconState.play.play) })} - ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - - - - - - - - -
- - - -
- - ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} - ${renderIcon('volume-low', { class: cn(icon, iconState.mute.volumeLow) })} - ${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })} - - - - - - - - - - - - - - -
-
-
- - - - - - - -
- `; -} diff --git a/site/scripts/ejected-skins/templates/html/live-audio/skin.tailwind.ts b/site/scripts/ejected-skins/templates/html/live-audio/skin.tailwind.ts deleted file mode 100644 index 1103be5ff8..0000000000 --- a/site/scripts/ejected-skins/templates/html/live-audio/skin.tailwind.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { renderIcon } from '@videojs/icons/render'; -import { - button, - buttonGroup, - container, - controls, - dialog, - icon, - iconState, - playButton, - popup, - slider, - spacer, -} from '@videojs/skins/default/tailwind/audio.tailwind'; -import { cn } from '@videojs/utils/style'; - -export function getTemplateHTML() { - return /*html*/ ` - - - - - - - -
- - -
-
- -
-
-
- -
- -
- - - ${renderIcon('spinner', { class: icon })} - - - ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} - ${renderIcon('play', { class: cn(icon, iconState.play.play) })} - ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - - - - - - - - -
- - - -
- - ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} - ${renderIcon('volume-low', { class: cn(icon, iconState.mute.volumeLow) })} - ${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })} - - - - - - - - - - -
-
-
- - - - - - - -
- `; -} diff --git a/site/scripts/ejected-skins/templates/html/live-video/minimal-skin.tailwind.ts b/site/scripts/ejected-skins/templates/html/live-video/minimal-skin.tailwind.ts deleted file mode 100644 index dbf166e245..0000000000 --- a/site/scripts/ejected-skins/templates/html/live-video/minimal-skin.tailwind.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { renderIcon } from '@videojs/icons/render/minimal'; -import { - controlsBackdrop, - bufferingIndicator, - button, - buttonGroupEnd, - buttonGroupStart, - container, - controls, - dialog, - icon, - iconState, - inputIndicator, - menu, - popup, - poster, - slider, - spacer, - statusIndicator, - volumeIndicator, -} from '@videojs/skins/minimal/tailwind/video.tailwind'; -import { cn } from '@videojs/utils/style'; - -export function getTemplateHTML() { - return /*html*/ ` - - - - - - - - - - - - - ${renderIcon('spinner')} - - - - - -
- - -
-
- -
-
-
- - - - - -
- - ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} - ${renderIcon('play', { class: cn(icon, iconState.play.play) })} - ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - - - - - - - - - - ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} - ${renderIcon('volume-low', { class: cn(icon, iconState.mute.volumeLow) })} - ${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })} - - - - - - - - - - - - - - -
- - - -
- - ${renderIcon('captions-off', { class: cn(icon, iconState.captions.off) })} - ${renderIcon('captions-on', { class: cn(icon, iconState.captions.on) })} - - - - - - - - - - - - - - ${renderIcon('cast-enter', { class: cn(icon, iconState.cast.enter) })} - ${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })} - - - - - - - ${renderIcon('airplay-enter', { class: cn(icon, iconState.airplay.enter) })} - ${renderIcon('airplay-exit', { class: cn(icon, iconState.airplay.exit) })} - - - - - - - ${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })} - ${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })} - - - - - - - ${renderIcon('fullscreen-enter', { class: cn(icon, iconState.fullscreen.enter) })} - ${renderIcon('fullscreen-exit', { class: cn(icon, iconState.fullscreen.exit) })} - - - - - -
-
-
-
- - - - - - - - - - - - - - - - - - -
- - - -
-
- `; -} diff --git a/site/scripts/ejected-skins/templates/html/live-video/skin.tailwind.ts b/site/scripts/ejected-skins/templates/html/live-video/skin.tailwind.ts deleted file mode 100644 index 8e8e3c43a6..0000000000 --- a/site/scripts/ejected-skins/templates/html/live-video/skin.tailwind.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { renderIcon } from '@videojs/icons/render'; -import { - controlsBackdrop, - bufferingIndicator, - button, - buttonGroupEnd, - buttonGroupStart, - container, - controls, - dialog, - icon, - iconState, - inputIndicator, - menu, - popup, - poster, - primaryControls, - slider, - spacer, - statusIndicator, - volumeIndicator, -} from '@videojs/skins/default/tailwind/video.tailwind'; -import { cn } from '@videojs/utils/style'; - -export function getTemplateHTML() { - return /*html*/ ` - - - - - - - - - - - - - ${renderIcon('spinner', { class: icon })} - - - - - -
- - -
-
- -
-
-
- - - - - - -
- - ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} - ${renderIcon('play', { class: cn(icon, iconState.play.play) })} - ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - - - - - - - -
- - - -
- - ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} - ${renderIcon('volume-low', { class: cn(icon, iconState.mute.volumeLow) })} - ${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })} - - - - - - - - - - - - ${renderIcon('captions-off', { class: cn(icon, iconState.captions.off) })} - ${renderIcon('captions-on', { class: cn(icon, iconState.captions.on) })} - - - - - - - - - - - - - - - ${renderIcon('cast-enter', { class: cn(icon, iconState.cast.enter) })} - ${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })} - - - - - - - - ${renderIcon('airplay-enter', { class: cn(icon, iconState.airplay.enter) })} - ${renderIcon('airplay-exit', { class: cn(icon, iconState.airplay.exit) })} - - - - - - - - ${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })} - ${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })} - - - - - - - - ${renderIcon('fullscreen-enter', { class: cn(icon, iconState.fullscreen.enter) })} - ${renderIcon('fullscreen-exit', { class: cn(icon, iconState.fullscreen.exit) })} - - - - - -
-
-
-
-
- - - - - - - - - - - - - - - - - - -
- - - -
-
- `; -} diff --git a/site/scripts/ejected-skins/templates/html/video/minimal-skin.tailwind.ts b/site/scripts/ejected-skins/templates/html/video/minimal-skin.tailwind.ts deleted file mode 100644 index 6e0867ba6e..0000000000 --- a/site/scripts/ejected-skins/templates/html/video/minimal-skin.tailwind.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { audioText, captionsText, qualityText, settingsText, speedText } from '@videojs/core/i18n/text/menu'; -import { renderText } from '@videojs/html/i18n'; -import { renderIcon } from '@videojs/icons/render/minimal'; -import { - badge, - controlsBackdrop, - bufferingIndicator, - button, - buttonGroupEnd, - buttonGroupStart, - container, - controls, - dialog, - icon, - iconFlipped, - iconState, - inputIndicator, - menu, - popup, - poster, - seekIndicator, - slider, - statusIndicator, - thumbnail, - time, - volumeIndicator, -} from '@videojs/skins/minimal/tailwind/video.tailwind'; -import { cn } from '@videojs/utils/style'; - -export function getTemplateHTML() { - return /*html*/ ` - - - - - - - - - - - - - ${renderIcon('spinner')} - - - - - -
- - -
-
- -
-
-
- - - - - -
- - ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} - ${renderIcon('play', { class: cn(icon, iconState.play.play) })} - ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - - - - - - - - ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} - ${renderIcon('volume-low', { class: cn(icon, iconState.mute.volumeLow) })} - ${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })} - - - - - - - - - - - - - -
- -
- - - - - - - - - - - - - -
- - ${renderIcon('spinner', { class: cn(icon, thumbnail.spinner) })} -
-
- - -
-
-
-
- - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - -
-
- `; -} diff --git a/site/scripts/ejected-skins/templates/html/video/skin.tailwind.ts b/site/scripts/ejected-skins/templates/html/video/skin.tailwind.ts deleted file mode 100644 index b12e3687b5..0000000000 --- a/site/scripts/ejected-skins/templates/html/video/skin.tailwind.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { audioText, captionsText, qualityText, settingsText, speedText } from '@videojs/core/i18n/text/menu'; -import { renderText } from '@videojs/html/i18n'; -import { renderIcon } from '@videojs/icons/render'; -import { - badge, - controlsBackdrop, - bufferingIndicator, - button, - buttonGroupEnd, - buttonGroupStart, - container, - controls, - dialog, - icon, - iconFlipped, - iconState, - inputIndicator, - menu, - popup, - poster, - primaryControls, - secondaryControls, - seekIndicator, - slider, - statusIndicator, - thumbnail, - time, - volumeIndicator, -} from '@videojs/skins/default/tailwind/video.tailwind'; -import { cn } from '@videojs/utils/style'; - -export function getTemplateHTML() { - return /*html*/ ` - - - - - - - - - - - - - ${renderIcon('spinner', { class: icon })} - - - - - -
- - -
-
- -
-
-
- - - - - - -
- - ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} - ${renderIcon('play', { class: cn(icon, iconState.play.play) })} - ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} - - - - - - - - ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} - ${renderIcon('volume-low', { class: cn(icon, iconState.mute.volumeLow) })} - ${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })} - - - - - - - - - - -
- -
- - - - - - - - -
- - ${renderIcon('spinner', { class: cn(icon, thumbnail.spinner) })} -
-
- - -
-
-
- -
- - -
- - -
- - ${renderIcon('cast-enter', { class: cn(icon, iconState.cast.enter) })} - ${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })} - - - - - - - ${renderIcon('airplay-enter', { class: cn(icon, iconState.airplay.enter) })} - ${renderIcon('airplay-exit', { class: cn(icon, iconState.airplay.exit) })} - - - - - - - ${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })} - ${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })} - - - - - - - ${renderIcon('fullscreen-enter', { class: cn(icon, iconState.fullscreen.enter) })} - ${renderIcon('fullscreen-exit', { class: cn(icon, iconState.fullscreen.exit) })} - - - - - -
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - -
-
- `; -} diff --git a/site/tsconfig.json b/site/tsconfig.json index 2cc1d2830b..5eefea30bd 100644 --- a/site/tsconfig.json +++ b/site/tsconfig.json @@ -11,5 +11,5 @@ "strictNullChecks": true }, "include": [".astro/types.d.ts", "**/*"], - "exclude": ["dist", "scripts/ejected-skins/templates"] + "exclude": ["dist"] }