diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 10e46598..a8491bc6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -64,6 +64,10 @@ jobs: - name: Run E2E tests run: pnpm --dir apps/e2e exec playwright test --project=vite-${{ matrix.browser }} + - name: Run sandbox E2E tests + if: matrix.browser == 'chromium' + run: pnpm --dir apps/e2e test:sandbox + - name: Upload test report if: always() uses: actions/upload-artifact@v4 diff --git a/apps/e2e/package.json b/apps/e2e/package.json index 191158e5..ae897160 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -17,6 +17,7 @@ "test:chromium": "playwright test --project=vite-chromium", "test:webkit": "playwright test --project=vite-webkit", "test:vite": "playwright test --project=vite-chromium", + "test:sandbox": "playwright test --project=sandbox-chromium", "test:all": "playwright test --project=vite-chromium --project=vite-webkit", "report": "playwright show-report" }, diff --git a/apps/e2e/playwright.config.ts b/apps/e2e/playwright.config.ts index 2342a9bb..a90aa81e 100644 --- a/apps/e2e/playwright.config.ts +++ b/apps/e2e/playwright.config.ts @@ -1,6 +1,52 @@ import { defineConfig, devices } from '@playwright/test'; const CI = !!process.env.CI; +const SANDBOX_SPEC = /sandbox-.*i18n\.spec\.ts/; +const VALUE_OPTIONS = new Set([ + '-c', + '-g', + '-j', + '-p', + '-u', + '--browser', + '--config', + '--global-timeout', + '--grep', + '--grep-invert', + '--max-failures', + '--output', + '--project', + '--repeat-each', + '--reporter', + '--retries', + '--shard', + '--test-list', + '--test-list-invert', + '--timeout', + '--trace', + '--tsconfig', + '--update-snapshots', + '--workers', +]); + +const args = process.argv.slice(2).filter((arg, index) => !(index === 0 && arg === 'test')); +const selectedProjects = args.flatMap((arg, index) => { + if (arg === '--project' || arg === '-p') { + return args[index + 1] ? [args[index + 1]] : []; + } + return arg.startsWith('--project=') ? [arg.slice('--project='.length)] : []; +}); +const testFilters = args.filter((arg, index) => !arg.startsWith('-') && !VALUE_OPTIONS.has(args[index - 1] ?? '')); +const selectedViteProject = selectedProjects.some((name) => name.startsWith('vite-')); +const selectedSandboxProject = selectedProjects.some((name) => name.startsWith('sandbox-')); +const selectedSandboxSpec = testFilters.some((arg) => SANDBOX_SPEC.test(arg)); +const unfilteredRun = selectedProjects.length === 0 && testFilters.length === 0; +const shouldStartViteServer = + selectedViteProject || + unfilteredRun || + (selectedProjects.length === 0 && testFilters.some((arg) => !SANDBOX_SPEC.test(arg))); +const shouldStartSandboxServer = + !process.env.SANDBOX_URL && (selectedSandboxProject || selectedSandboxSpec || unfilteredRun); export default defineConfig({ testDir: './tests', @@ -34,21 +80,30 @@ export default defineConfig({ // --- Chromium --- { name: 'vite-chromium', + testIgnore: SANDBOX_SPEC, use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:5180' }, }, // --- WebKit --- { name: 'vite-webkit', + testIgnore: SANDBOX_SPEC, use: { ...devices['Desktop Safari'], baseURL: 'http://localhost:5180' }, }, // --- Firefox --- { name: 'vite-firefox', + testIgnore: SANDBOX_SPEC, use: { ...devices['Desktop Firefox'], baseURL: 'http://localhost:5180' }, }, + { + name: 'sandbox-chromium', + testMatch: SANDBOX_SPEC, + use: { ...devices['Desktop Chrome'] }, + }, + // --- Future: Next.js --- // { // name: 'next-chromium', @@ -63,13 +118,29 @@ export default defineConfig({ ], webServer: [ - { - command: 'npx vite --port 5180', - cwd: './apps/vite', - port: 5180, - reuseExistingServer: !CI, - timeout: 120_000, - }, + ...(shouldStartViteServer + ? [ + { + command: 'npx vite --port 5180', + cwd: './apps/vite', + port: 5180, + reuseExistingServer: !CI, + timeout: 120_000, + }, + ] + : []), + ...(shouldStartSandboxServer + ? [ + { + command: + 'pnpm --dir ../.. build:cdn && node_modules/.bin/tsx scripts/setup.ts && node_modules/.bin/vite --host --port 5299', + cwd: '../sandbox', + port: 5299, + reuseExistingServer: !CI, + timeout: 300_000, + }, + ] + : []), // --- Future: Next.js --- // { diff --git a/apps/e2e/tests/sandbox-cdn-i18n.spec.ts b/apps/e2e/tests/sandbox-cdn-i18n.spec.ts new file mode 100644 index 00000000..feddc834 --- /dev/null +++ b/apps/e2e/tests/sandbox-cdn-i18n.spec.ts @@ -0,0 +1,47 @@ +import { expect, type Frame, type Page, test } from '@playwright/test'; +import { SELECTORS } from '../fixtures/selectors'; +import { PlayerPage } from '../page-objects/player'; + +const SANDBOX_BASE = process.env.SANDBOX_URL ?? 'http://localhost:5299'; + +test.use({ trace: 'off' }); + +async function getPreviewFrame(page: Page, path: string): Promise { + await expect(page.locator('iframe[title="player demo"]')).toHaveAttribute('src', new RegExp(`^${path}`)); + await expect + .poll(() => + page + .frames() + .find((frame) => frame.url().includes(path)) + ?.url() + ) + .toContain(path); + + const frame = page.frames().find((frame) => frame.url().includes(path)); + if (!frame) throw new Error(`Preview frame not found: ${path}`); + + return frame; +} + +test.describe('Sandbox CDN i18n', () => { + test('direct CDN page shows Spanish play label', async ({ page }) => { + await page.goto( + `${SANDBOX_BASE}/cdn/?preset=video&locale=es&styling=css&skin=default&source=hls-1&autoplay=0&muted=0&loop=0&preload=metadata`, + { waitUntil: 'domcontentloaded' } + ); + const player = new PlayerPage(page); + await expect(player.playButton).toHaveAttribute('aria-label', 'Reproducir', { timeout: 15_000 }); + }); + + test('shell iframe shows Spanish play label', async ({ page }) => { + await page.goto( + `${SANDBOX_BASE}/?platform=cdn&preset=video&locale=es&styling=css&skin=default&source=hls-1&autoplay=0&muted=0&loop=0&preload=metadata`, + { waitUntil: 'domcontentloaded' } + ); + const frame = await getPreviewFrame(page, '/cdn/'); + const playButton = frame.locator(SELECTORS.playButton).first(); + await expect(playButton).toHaveAttribute('aria-label', 'Reproducir', { + timeout: 15_000, + }); + }); +}); diff --git a/apps/e2e/tests/sandbox-html-i18n.spec.ts b/apps/e2e/tests/sandbox-html-i18n.spec.ts new file mode 100644 index 00000000..d76a7d8a --- /dev/null +++ b/apps/e2e/tests/sandbox-html-i18n.spec.ts @@ -0,0 +1,60 @@ +import { expect, type Frame, type Page, test } from '@playwright/test'; +import { SELECTORS } from '../fixtures/selectors'; + +const SANDBOX_BASE = process.env.SANDBOX_URL ?? 'http://localhost:5299'; + +const QUERY = 'locale=es&styling=css&skin=default&source=hls-1&autoplay=0&muted=0&loop=0&preload=metadata'; + +test.use({ trace: 'off' }); + +async function expectSpanishPlayLabel(scope: Page | Frame): Promise { + const playButton = scope.locator(SELECTORS.playButton).first(); + await expect(playButton).toHaveAttribute('aria-label', 'Reproducir', { timeout: 15_000 }); +} + +async function getPreviewFrame(page: Page, path: string): Promise { + await expect(page.locator('iframe[title="player demo"]')).toHaveAttribute('src', new RegExp(`^${path}`)); + await expect + .poll(() => + page + .frames() + .find((frame) => frame.url().includes(path)) + ?.url() + ) + .toContain(path); + + const frame = page.frames().find((frame) => frame.url().includes(path)); + if (!frame) throw new Error(`Preview frame not found: ${path}`); + + return frame; +} + +test.describe('Sandbox HTML i18n', () => { + test('direct HTML page shows Spanish play label', async ({ page }) => { + await page.goto(`${SANDBOX_BASE}/html-video/?${QUERY}`, { waitUntil: 'domcontentloaded' }); + await expectSpanishPlayLabel(page); + }); + + test('shell iframe shows Spanish play label', async ({ page }) => { + await page.goto(`${SANDBOX_BASE}/?platform=html&preset=video&${QUERY}`, { + waitUntil: 'domcontentloaded', + }); + const frame = await getPreviewFrame(page, '/html-video/'); + await expectSpanishPlayLabel(frame); + }); +}); + +test.describe('Sandbox React i18n', () => { + test('direct React page shows Spanish play label', async ({ page }) => { + await page.goto(`${SANDBOX_BASE}/react-video/?${QUERY}`, { waitUntil: 'domcontentloaded' }); + await expectSpanishPlayLabel(page); + }); + + test('shell iframe shows Spanish play label', async ({ page }) => { + await page.goto(`${SANDBOX_BASE}/?platform=react&preset=video&${QUERY}`, { + waitUntil: 'domcontentloaded', + }); + const frame = await getPreviewFrame(page, '/react-video/'); + await expectSpanishPlayLabel(frame); + }); +}); diff --git a/apps/sandbox/README.md b/apps/sandbox/README.md index cf755108..e7935551 100644 --- a/apps/sandbox/README.md +++ b/apps/sandbox/README.md @@ -12,6 +12,8 @@ pnpm dev # also runs the docs site Open the printed URL. The root route renders an interactive shell — a navbar with dropdowns for platform (HTML, React, CDN), preset (`video`, `hlsjs-video`, `audio`, etc.), skin, styling (CSS or Tailwind), and source — that previews the selected combination in an iframe. Use the **Open** button to pop the preview out into its own tab. +**Language** is in **Player settings** (gear icon) for every preset (HTML, React, and CDN). **CDN** registers copy through `@videojs/html/cdn/i18n` (the same registry as the CDN player bundle), not source `@videojs/html/i18n`. After pulling template changes, restart `pnpm dev:sandbox` so `scripts/setup.ts` refreshes `src/` from `templates/`. + The shell covers the main combinatorial matrix. One-off templates not in that matrix (e.g. `firefox-mse-repro`, `spf-segment-loading`, `simple-hls-html`) are reachable by navigating directly to `//`. See `apps/sandbox/templates/` for the full list. ## How it works @@ -22,7 +24,7 @@ Three directories participate: - **`templates/`** — The source of truth for each sandbox. One subdirectory per entry point, each containing its own `index.html` and `main.ts` / `main.tsx`. Checked into git. - **`src/`** — Your working copy where you freely edit, experiment, and break things. Fully gitignored (`src/*`). -On `pnpm dev:sandbox`, `scripts/setup.ts` mirrors every file from `templates/` into `src/` that doesn't already exist there. Existing files in `src/` are never overwritten, so your local changes persist across restarts. +On `pnpm dev:sandbox`, `scripts/setup.ts` mirrors new files from `templates/` into `src/` and overwrites `src/` files that differ from `templates/`. Edits made only in `src/` are replaced on the next dev start — use `sync` to copy them into `templates/` first. Vite discovers sandbox entries by scanning `src/*` for subdirectories that contain an `index.html` — no manual registration is needed. diff --git a/apps/sandbox/app/shared/html/i18n.ts b/apps/sandbox/app/shared/html/i18n.ts new file mode 100644 index 00000000..d46d1dfc --- /dev/null +++ b/apps/sandbox/app/shared/html/i18n.ts @@ -0,0 +1,42 @@ +import '@videojs/html/i18n'; + +import { ensureSandboxLocale, type SandboxLocaleTag } from '../i18n/sandbox-locales'; +import { getInitialLocale, onLocaleChange } from '../sandbox-listener'; + +let locale: SandboxLocaleTag = getInitialLocale(); +let localeApplySeq = 0; + +document.documentElement.lang = locale; + +export function wrapSandboxHtmlI18n(content: string): string { + return `${content}`; +} + +export async function prepareSandboxHtmlLocale(): Promise { + await ensureSandboxLocale(locale); +} + +export async function applySandboxHtmlLocale(next: SandboxLocaleTag): Promise { + const seq = ++localeApplySeq; + await ensureSandboxLocale(next); + if (seq !== localeApplySeq) return; + locale = next; + document.documentElement.lang = locale; +} + +export function bindSandboxHtmlLocaleChange(rerender: () => void): void { + onLocaleChange((next) => { + if (document.querySelector('media-i18n')) { + void applySandboxHtmlLocale(next); + return; + } + const seq = ++localeApplySeq; + void (async () => { + await ensureSandboxLocale(next); + if (seq !== localeApplySeq) return; + locale = next; + document.documentElement.lang = locale; + rerender(); + })(); + }); +} diff --git a/apps/sandbox/app/shared/i18n/browser-sandbox-prefetch.ts b/apps/sandbox/app/shared/i18n/browser-sandbox-prefetch.ts new file mode 100644 index 00000000..01b2e40f --- /dev/null +++ b/apps/sandbox/app/shared/i18n/browser-sandbox-prefetch.ts @@ -0,0 +1,37 @@ +import type { Locale, Translations } from '@videojs/core/i18n'; +import { getBrowserTranslations } from '@videojs/core/i18n'; + +import { type SandboxBrowserLocaleTag, sandboxLocaleLabel } from './locale-meta'; + +type RegisterI18n = (locale: Locale, translations: Partial) => void; + +/** Dedupes in-flight browser model download + translation per sandbox browser-only tag. */ +export function createBrowserSandboxPrefetch(register: RegisterI18n) { + const inflight = new Map>(); + + return async function prefetchBrowserSandboxLocale(tag: SandboxBrowserLocaleTag): Promise { + const pending = inflight.get(tag); + if (pending) return pending; + + const task = (async () => { + const label = sandboxLocaleLabel(tag); + const browser = await getBrowserTranslations(tag, { + downloadIfNeeded: true, + onModelDownload: { + start: () => { + console.info(`[videojs/sandbox] Downloading browser translation model for ${label} (${tag})…`); + }, + finish: () => { + console.info(`[videojs/sandbox] Browser translation model ready for ${label} (${tag})`); + }, + }, + }); + if (Object.keys(browser).length) register(tag, browser); + })().finally(() => { + inflight.delete(tag); + }); + + inflight.set(tag, task); + return task; + }; +} diff --git a/apps/sandbox/app/shared/i18n/cdn-locale-loaders.generated.ts b/apps/sandbox/app/shared/i18n/cdn-locale-loaders.generated.ts new file mode 100644 index 00000000..88919c53 --- /dev/null +++ b/apps/sandbox/app/shared/i18n/cdn-locale-loaders.generated.ts @@ -0,0 +1,57 @@ +/** Generated by scripts/generate-cdn-locale-loaders.ts — do not edit. */ +import type { SandboxLocalePack } from './locale-meta'; + +/** CDN locale chunks register via `../i18n.dev.js` — same module graph as `@videojs/html/cdn/video`. */ +export const cdnLocaleLoaders = { + ar: () => import('@videojs/html/cdn/locales/ar'), + az: () => import('@videojs/html/cdn/locales/az'), + bg: () => import('@videojs/html/cdn/locales/bg'), + bn: () => import('@videojs/html/cdn/locales/bn'), + bs: () => import('@videojs/html/cdn/locales/bs'), + ca: () => import('@videojs/html/cdn/locales/ca'), + cs: () => import('@videojs/html/cdn/locales/cs'), + cy: () => import('@videojs/html/cdn/locales/cy'), + da: () => import('@videojs/html/cdn/locales/da'), + de: () => import('@videojs/html/cdn/locales/de'), + el: () => import('@videojs/html/cdn/locales/el'), + es: () => import('@videojs/html/cdn/locales/es'), + et: () => import('@videojs/html/cdn/locales/et'), + eu: () => import('@videojs/html/cdn/locales/eu'), + fa: () => import('@videojs/html/cdn/locales/fa'), + fi: () => import('@videojs/html/cdn/locales/fi'), + fr: () => import('@videojs/html/cdn/locales/fr'), + gd: () => import('@videojs/html/cdn/locales/gd'), + gl: () => import('@videojs/html/cdn/locales/gl'), + he: () => import('@videojs/html/cdn/locales/he'), + hi: () => import('@videojs/html/cdn/locales/hi'), + hr: () => import('@videojs/html/cdn/locales/hr'), + hu: () => import('@videojs/html/cdn/locales/hu'), + it: () => import('@videojs/html/cdn/locales/it'), + ja: () => import('@videojs/html/cdn/locales/ja'), + ko: () => import('@videojs/html/cdn/locales/ko'), + lv: () => import('@videojs/html/cdn/locales/lv'), + mr: () => import('@videojs/html/cdn/locales/mr'), + nb: () => import('@videojs/html/cdn/locales/nb'), + ne: () => import('@videojs/html/cdn/locales/ne'), + nl: () => import('@videojs/html/cdn/locales/nl'), + nn: () => import('@videojs/html/cdn/locales/nn'), + oc: () => import('@videojs/html/cdn/locales/oc'), + pl: () => import('@videojs/html/cdn/locales/pl'), + pt: () => import('@videojs/html/cdn/locales/pt'), + 'pt-BR': () => import('@videojs/html/cdn/locales/pt-BR'), + 'pt-PT': () => import('@videojs/html/cdn/locales/pt-PT'), + ro: () => import('@videojs/html/cdn/locales/ro'), + ru: () => import('@videojs/html/cdn/locales/ru'), + sk: () => import('@videojs/html/cdn/locales/sk'), + sl: () => import('@videojs/html/cdn/locales/sl'), + sr: () => import('@videojs/html/cdn/locales/sr'), + sv: () => import('@videojs/html/cdn/locales/sv'), + te: () => import('@videojs/html/cdn/locales/te'), + th: () => import('@videojs/html/cdn/locales/th'), + tr: () => import('@videojs/html/cdn/locales/tr'), + uk: () => import('@videojs/html/cdn/locales/uk'), + vi: () => import('@videojs/html/cdn/locales/vi'), + zh: () => import('@videojs/html/cdn/locales/zh'), + 'zh-CN': () => import('@videojs/html/cdn/locales/zh-CN'), + 'zh-TW': () => import('@videojs/html/cdn/locales/zh-TW'), +} as const satisfies Record, () => Promise>; diff --git a/apps/sandbox/app/shared/i18n/cdn-sandbox-locales.ts b/apps/sandbox/app/shared/i18n/cdn-sandbox-locales.ts new file mode 100644 index 00000000..02049109 --- /dev/null +++ b/apps/sandbox/app/shared/i18n/cdn-sandbox-locales.ts @@ -0,0 +1,48 @@ +import { getI18nTranslations, registerI18n } from '@videojs/html/cdn/i18n'; + +import { createBrowserSandboxPrefetch } from './browser-sandbox-prefetch'; +import { cdnLocaleLoaders } from './cdn-locale-loaders.generated'; +import { isSandboxBrowserLocale, type SandboxLocaleTag } from './locale-meta'; + +const prefetchBrowserSandboxLocale = createBrowserSandboxPrefetch(registerI18n); + +let active: SandboxLocaleTag | null = null; + +/** Loads a CDN locale chunk or prefetches browser translations before the player renders. */ +export async function ensureCdnSandboxLocale(tag: SandboxLocaleTag): Promise { + if (tag === 'en') { + active = tag; + return; + } + + if (isSandboxBrowserLocale(tag)) { + if (active !== tag) { + await prefetchBrowserSandboxLocale(tag); + } + active = tag; + return; + } + + if (active === tag) { + return; + } + + const load = cdnLocaleLoaders[tag as keyof typeof cdnLocaleLoaders]; + if (!load) { + throw new Error(`Unknown sandbox locale: ${tag}`); + } + + await load(); + + if (import.meta.env.DEV) { + const registered = getI18nTranslations(tag).Play; + const enPlay = getI18nTranslations('en').Play; + if (registered === enPlay) { + throw new Error( + `[videojs/sandbox] Locale "${tag}" did not register on the CDN i18n registry (still "${enPlay}").` + ); + } + } + + active = tag; +} diff --git a/apps/sandbox/app/shared/i18n/locale-meta.ts b/apps/sandbox/app/shared/i18n/locale-meta.ts new file mode 100644 index 00000000..cd3a51b8 --- /dev/null +++ b/apps/sandbox/app/shared/i18n/locale-meta.ts @@ -0,0 +1,114 @@ +import { LOCALES, localeAliases } from '@videojs/core/i18n'; + +/** Locale packs available through Video.js (picker + CDN loaders). */ +export const SANDBOX_LOCALE_PACKS = ['en', ...LOCALES, ...localeAliases(LOCALES)] as const; + +export type SandboxLocalePack = (typeof SANDBOX_LOCALE_PACKS)[number]; + +/** + * Chrome Translator API tags (desktop). + * @see https://developer.chrome.com/docs/ai/translator-api#supported_languages + */ +const CHROME_TRANSLATOR_LOCALE_TAGS = [ + 'ar', + 'bg', + 'bn', + 'cs', + 'da', + 'de', + 'el', + 'en', + 'es', + 'fi', + 'fr', + 'hi', + 'hr', + 'hu', + 'id', + 'it', + 'iw', + 'ja', + 'kn', + 'ko', + 'lt', + 'mr', + 'nl', + 'no', + 'pl', + 'pt', + 'ro', + 'ru', + 'sk', + 'sl', + 'sv', + 'ta', + 'te', + 'th', + 'tr', + 'uk', + 'vi', + 'zh', + 'zh-Hant', +] as const; + +const sandboxLocalePackSet = new Set(SANDBOX_LOCALE_PACKS); + +export type SandboxBrowserLocaleTag = Extract< + (typeof CHROME_TRANSLATOR_LOCALE_TAGS)[number], + Exclude<(typeof CHROME_TRANSLATOR_LOCALE_TAGS)[number], SandboxLocalePack | 'en'> +>; + +/** + * Locales with no Video.js pack — Chrome Translator only, excluding tags we already ship. + * Derived from {@link CHROME_TRANSLATOR_LOCALE_TAGS} so unsupported tags never appear in the picker. + */ +export const SANDBOX_BROWSER_LOCALE_TAGS: readonly SandboxBrowserLocaleTag[] = CHROME_TRANSLATOR_LOCALE_TAGS.filter( + (tag): tag is SandboxBrowserLocaleTag => tag !== 'en' && !sandboxLocalePackSet.has(tag) +); + +/** All tags exposed in the sandbox language picker. */ +export type SandboxLocaleTag = SandboxLocalePack | SandboxBrowserLocaleTag; + +export const SANDBOX_LOCALE_TAGS: readonly SandboxLocaleTag[] = [ + ...SANDBOX_LOCALE_PACKS, + ...SANDBOX_BROWSER_LOCALE_TAGS, +]; + +export const DEFAULT_SANDBOX_LOCALE: SandboxLocaleTag = 'en'; + +const LANGUAGE_NAMES = new Intl.DisplayNames('en', { type: 'language' }); + +export function sandboxLocaleLabel(tag: string): string { + return LANGUAGE_NAMES.of(tag) ?? tag; +} + +export function isSandboxBrowserLocale(tag: string): tag is SandboxBrowserLocaleTag { + return (SANDBOX_BROWSER_LOCALE_TAGS as readonly string[]).includes(tag); +} + +export type SandboxLocaleOption = { + value: SandboxLocaleTag; + label: string; +}; + +export type SandboxLocaleOptionGroup = { + label: string; + options: SandboxLocaleOption[]; +}; + +function localeOptions(tags: readonly SandboxLocaleTag[]): SandboxLocaleOption[] { + return [...tags] + .map((tag) => ({ + value: tag, + label: sandboxLocaleLabel(tag), + })) + .sort((a, b) => a.label.localeCompare(b.label, 'en')); +} + +/** Flat list (legacy); prefer {@link SANDBOX_LOCALE_OPTION_GROUPS} in the picker. */ +export const SANDBOX_LOCALE_OPTIONS = localeOptions(SANDBOX_LOCALE_TAGS); + +export const SANDBOX_LOCALE_OPTION_GROUPS: SandboxLocaleOptionGroup[] = [ + { label: 'Built-in packs', options: localeOptions(SANDBOX_LOCALE_PACKS) }, + { label: 'Browser API only', options: localeOptions(SANDBOX_BROWSER_LOCALE_TAGS) }, +]; diff --git a/apps/sandbox/app/shared/i18n/sandbox-locales.ts b/apps/sandbox/app/shared/i18n/sandbox-locales.ts new file mode 100644 index 00000000..679f293b --- /dev/null +++ b/apps/sandbox/app/shared/i18n/sandbox-locales.ts @@ -0,0 +1,55 @@ +import { registerI18n } from '@videojs/html/i18n'; +import { all } from '@videojs/html/i18n/locales/all'; + +import { createBrowserSandboxPrefetch } from './browser-sandbox-prefetch'; +import { + DEFAULT_SANDBOX_LOCALE, + isSandboxBrowserLocale, + SANDBOX_LOCALE_OPTION_GROUPS, + SANDBOX_LOCALE_OPTIONS, + SANDBOX_LOCALE_TAGS, + type SandboxLocaleTag, + sandboxLocaleLabel, +} from './locale-meta'; + +export { + DEFAULT_SANDBOX_LOCALE, + isSandboxBrowserLocale, + SANDBOX_LOCALE_OPTION_GROUPS, + SANDBOX_LOCALE_OPTIONS, + SANDBOX_LOCALE_TAGS, + sandboxLocaleLabel, +}; +export type { SandboxLocaleTag }; + +const prefetchBrowserSandboxLocale = createBrowserSandboxPrefetch(registerI18n); + +let activeLocale: SandboxLocaleTag | null = null; + +/** Registers built-in packs or prefetches browser translations before the player renders. */ +export async function ensureSandboxLocale(tag: SandboxLocaleTag): Promise { + if (tag === 'en') { + activeLocale = 'en'; + return; + } + + if (isSandboxBrowserLocale(tag)) { + if (activeLocale !== tag) { + await prefetchBrowserSandboxLocale(tag); + } + activeLocale = tag; + return; + } + + if (activeLocale === tag) { + return; + } + + const translations = all[tag as keyof typeof all]; + if (!translations) { + throw new Error(`Unknown sandbox locale: ${tag}`); + } + + registerI18n(tag, translations); + activeLocale = tag; +} diff --git a/apps/sandbox/app/shared/react/sandbox-i18n.tsx b/apps/sandbox/app/shared/react/sandbox-i18n.tsx new file mode 100644 index 00000000..5a0c0240 --- /dev/null +++ b/apps/sandbox/app/shared/react/sandbox-i18n.tsx @@ -0,0 +1,26 @@ +import { ensureSandboxLocale } from '@app/shared/i18n/sandbox-locales'; +import { I18nProvider } from '@videojs/react/i18n'; +import { type ReactNode, useEffect } from 'react'; + +import { useLocale } from './use-locale'; + +function syncHtmlLang(locale: string): void { + if (typeof document === 'undefined' || document.documentElement.lang === locale) return; + document.documentElement.lang = locale; +} + +/** Composes React i18n and syncs `` for sandbox locale demos. */ +export function SandboxI18nProvider({ children }: { children: ReactNode }) { + const locale = useLocale(); + + syncHtmlLang(locale); + + useEffect(() => { + syncHtmlLang(locale); + void ensureSandboxLocale(locale).catch(() => { + // I18nProvider still lazy-loads registry packs when prefetch fails. + }); + }, [locale]); + + return {children}; +} diff --git a/apps/sandbox/app/shared/react/use-locale.ts b/apps/sandbox/app/shared/react/use-locale.ts new file mode 100644 index 00000000..aa042cb9 --- /dev/null +++ b/apps/sandbox/app/shared/react/use-locale.ts @@ -0,0 +1,11 @@ +import type { SandboxLocaleTag } from '@app/shared/i18n/locale-meta'; +import { getInitialLocale, onLocaleChange } from '@app/shared/sandbox-listener'; +import { useEffect, useState } from 'react'; + +export function useLocale(): SandboxLocaleTag { + const [locale, setLocale] = useState(getInitialLocale); + + useEffect(() => onLocaleChange(setLocale), []); + + return locale; +} diff --git a/apps/sandbox/app/shared/sandbox-listener.ts b/apps/sandbox/app/shared/sandbox-listener.ts index 90a242bc..39cde642 100644 --- a/apps/sandbox/app/shared/sandbox-listener.ts +++ b/apps/sandbox/app/shared/sandbox-listener.ts @@ -1,4 +1,5 @@ import { SKINS } from '@app/constants'; +import { DEFAULT_SANDBOX_LOCALE, SANDBOX_LOCALE_TAGS, type SandboxLocaleTag } from '@app/shared/i18n/locale-meta'; import type { Skin } from '@app/types'; import { DEFAULT_AUDIO_SOURCE, SOURCES, type SourceId } from './sources'; @@ -35,6 +36,12 @@ let currentAutoplay = readBoolean('autoplay'); let currentMuted = readBoolean('muted'); let currentLoop = readBoolean('loop'); let currentPreload = readPreload(); +let currentLocale = readLocale(); + +function readLocale(): SandboxLocaleTag { + const value = params.get('locale'); + return SANDBOX_LOCALE_TAGS.includes(value as SandboxLocaleTag) ? (value as SandboxLocaleTag) : DEFAULT_SANDBOX_LOCALE; +} export function getInitialSkin(): Skin { return currentSkin; @@ -155,3 +162,22 @@ export function onPreloadChange(callback: (preload: PreloadValue) => void): () = window.removeEventListener('message', handler); }; } + +export function getInitialLocale(): SandboxLocaleTag { + return currentLocale; +} + +export function onLocaleChange(callback: (locale: SandboxLocaleTag) => void): () => void { + const handler = (event: MessageEvent) => { + if (event.data?.type !== 'locale-change' || !SANDBOX_LOCALE_TAGS.includes(event.data.locale)) return; + + currentLocale = event.data.locale; + callback(currentLocale); + }; + + window.addEventListener('message', handler); + + return () => { + window.removeEventListener('message', handler); + }; +} diff --git a/apps/sandbox/app/shell/app.tsx b/apps/sandbox/app/shell/app.tsx index 223424ba..8448ed9a 100644 --- a/apps/sandbox/app/shell/app.tsx +++ b/apps/sandbox/app/shell/app.tsx @@ -1,4 +1,5 @@ import { PLATFORMS, PRESETS, STYLINGS } from '@app/constants'; +import { DEFAULT_SANDBOX_LOCALE, SANDBOX_LOCALE_TAGS, type SandboxLocaleTag } from '@app/shared/i18n/locale-meta'; import { DEFAULT_PRELOAD, PRELOAD_VALUES, type PreloadValue } from '@app/shared/sandbox-listener'; import type { SourceId } from '@app/shared/sources'; import { @@ -35,6 +36,12 @@ function readParams() { muted: params.get('muted') === '1', loop: params.get('loop') === '1', preload: PRELOAD_VALUES.includes(preload as PreloadValue) ? (preload as PreloadValue) : DEFAULT_PRELOAD, + locale: (() => { + const value = params.get('locale'); + return SANDBOX_LOCALE_TAGS.includes(value as SandboxLocaleTag) + ? (value as SandboxLocaleTag) + : DEFAULT_SANDBOX_LOCALE; + })(), }; } @@ -49,6 +56,7 @@ export function App() { const [muted, setMuted] = useState(initial.muted); const [loop, setLoop] = useState(initial.loop); const [preload, setPreload] = useState(initial.preload); + const [locale, setLocale] = useState(initial.locale); const iframeRef = useRef(null); const pagePath = getPagePath(platform, preset); @@ -65,9 +73,10 @@ export function App() { muted: muted ? '1' : '0', loop: loop ? '1' : '0', preload, + locale, }); history.replaceState(null, '', `/?${params}`); - }, [platform, styling, preset, skin, source, autoplay, muted, loop, preload]); + }, [platform, styling, preset, skin, source, autoplay, muted, loop, preload, locale]); useEffect(() => { iframeRef.current?.contentWindow?.postMessage({ type: 'skin-change', skin }, '*'); @@ -93,26 +102,30 @@ export function App() { iframeRef.current?.contentWindow?.postMessage({ type: 'preload-change', preload }, '*'); }, [preload]); + useEffect(() => { + iframeRef.current?.contentWindow?.postMessage({ type: 'locale-change', locale }, '*'); + }, [locale]); + // Constrain source to MP4 when switching to audio useEffect(() => { if (preset === 'audio' && SOURCES[source].type !== 'mp4') { setSource(DEFAULT_AUDIO_SOURCE); } - }, [preset, source, setSource]); + }, [preset, source]); // Constrain source to DASH when switching to dash-video useEffect(() => { if (preset === 'dash-video' && SOURCES[source].type !== 'dash') { setSource(DEFAULT_DASH_SOURCE); } - }, [preset, source, setSource]); + }, [preset, source]); // Constrain source away from DASH for non-DASH presets useEffect(() => { if (preset !== 'dash-video' && SOURCES[source].type === 'dash') { setSource(DEFAULT_SOURCE); } - }, [preset, source, setSource]); + }, [preset, source]); // CDN, background video, and vimeo video do not have a Tailwind skin variant. useEffect(() => { @@ -124,7 +137,7 @@ export function App() { const availableSources = preset === 'audio' ? MP4_SOURCE_IDS : preset === 'dash-video' ? DASH_SOURCE_IDS : NON_DASH_SOURCE_IDS; - const handleSourceChange = useCallback((value: string) => setSource(value as SourceId), [setSource]); + const handleSourceChange = useCallback((value: string) => setSource(value as SourceId), []); return (
@@ -147,6 +160,8 @@ export function App() { onLoopChange={setLoop} preload={preload} onPreloadChange={setPreload} + locale={locale} + onLocaleChange={setLocale} availableSources={availableSources} isBackgroundVideo={preset === 'background-video'} isSimpleHls={preset.startsWith('simple-hls-')} @@ -170,6 +185,7 @@ export function App() { muted={muted} loop={loop} preload={preload} + locale={locale} />
); diff --git a/apps/sandbox/app/shell/navbar.tsx b/apps/sandbox/app/shell/navbar.tsx index 3e267fe7..424cd5a1 100644 --- a/apps/sandbox/app/shell/navbar.tsx +++ b/apps/sandbox/app/shell/navbar.tsx @@ -1,4 +1,5 @@ import type { SKINS } from '@app/constants'; +import { SANDBOX_LOCALE_OPTION_GROUPS, type SandboxLocaleTag } from '@app/shared/i18n/locale-meta'; import { PRELOAD_VALUES, type PreloadValue } from '@app/shared/sandbox-listener'; import type { SourceId } from '@app/shared/sources'; import type { Platform, Preset, Skin, Styling } from '@app/types'; @@ -23,6 +24,8 @@ type NavbarProps = { onLoopChange: (value: boolean) => void; preload: PreloadValue; onPreloadChange: (value: PreloadValue) => void; + locale: SandboxLocaleTag; + onLocaleChange: (value: SandboxLocaleTag) => void; availableSources: readonly SourceId[]; isBackgroundVideo: boolean; isSimpleHls: boolean; @@ -76,6 +79,8 @@ export function Navbar({ onLoopChange, preload, onPreloadChange, + locale, + onLocaleChange, availableSources, isBackgroundVideo, isSimpleHls, @@ -154,6 +159,8 @@ export function Navbar({ onLoopChange={onLoopChange} preload={preload} onPreloadChange={onPreloadChange} + locale={locale} + onLocaleChange={onLocaleChange} /> void; preload: PreloadValue; onPreloadChange: (value: PreloadValue) => void; + locale: SandboxLocaleTag; + onLocaleChange: (value: SandboxLocaleTag) => void; }; function SettingsMenu({ @@ -201,6 +210,8 @@ function SettingsMenu({ onLoopChange, preload, onPreloadChange, + locale, + onLocaleChange, }: SettingsMenuProps) { const [open, setOpen] = useState(false); const containerRef = useRef(null); @@ -209,6 +220,7 @@ function SettingsMenu({ const mutedId = useId(); const loopId = useId(); const preloadId = useId(); + const localeId = useId(); useEffect(() => { if (!open) return; @@ -265,8 +277,15 @@ function SettingsMenu({