mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
1042 lines
47 KiB
Markdown
1042 lines
47 KiB
Markdown
---
|
|
status: draft
|
|
date: 2026-03-25
|
|
---
|
|
|
|
# Internationalization (i18n)
|
|
|
|
Opaque-key translation system with a global registry and ES module locale files for CDN, typed hooks for React.
|
|
|
|
## Problem
|
|
|
|
All `aria-label`, `aria-valuetext`, tooltip labels, and time unit strings in Video.js 10 are hardcoded English. `PlayButtonCore.getLabel()` returns `'play'`. `formatDuration` returns `'2 minutes, 30 seconds'`. There is no mechanism to supply translated strings without overriding each component's `label` prop individually, and no single language switch for skins.
|
|
|
|
Requirements:
|
|
|
|
- Single entry point — one API replaces all English defaults regardless of layer (HTML, React, CDN)
|
|
- Stable keys — renaming an English label must not require updating every locale file
|
|
- Typed keys — TypeScript autocomplete for all keys
|
|
- Decoupled — i18n providers are not embedded in skins; any skin works with any provider
|
|
- Works without skins — standalone VJS components (buttons, sliders, time elements used outside of `<video-skin>`) can consume a provider directly
|
|
- CDN-compatible — ES module locale files self-register with no bundler and no global namespace pollution
|
|
- No side-effect locale imports — bundlers don't accidentally include all languages
|
|
- Framework-agnostic core — the registry and translator live in `@videojs/core/i18n`, re-exported to consumers as `@videojs/html/i18n` and `@videojs/react/i18n`
|
|
- Locale-aware — native `Intl` APIs handle duration, number, and plural formatting
|
|
|
|
## API
|
|
|
|
### Registering translations
|
|
|
|
The single entry point for both HTML and React consumers:
|
|
|
|
```ts
|
|
// HTML
|
|
import { registerI18n } from '@videojs/html/i18n';
|
|
import es from '@videojs/html/i18n/locales/es';
|
|
|
|
// React
|
|
import { registerI18n } from '@videojs/react';
|
|
import es from '@videojs/react/i18n/locales/es';
|
|
|
|
registerI18n('es', es);
|
|
```
|
|
|
|
`registerI18n` merges (does not replace) — multiple calls for the same language are additive. English defaults (`en.ts`) are pre-registered as the base layer on module import.
|
|
|
|
### HTML
|
|
|
|
`<media-i18n-provider>` reads from the nearest ancestor `lang` attribute, matching the inheritance model of the native HTML `lang` attribute. Setting `lang` on `<html>` — or any ancestor — is enough:
|
|
|
|
```html
|
|
<html lang="es">
|
|
<body>
|
|
<video-player>
|
|
<media-i18n-provider>
|
|
<video-skin>
|
|
<video src="..." playsinline></video>
|
|
</video-skin>
|
|
</media-i18n-provider>
|
|
</video-player>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
An explicit `lang` attribute on the provider overrides the inherited value — useful when a single page hosts players in different languages:
|
|
|
|
```html
|
|
<html lang="en">
|
|
<media-i18n-provider lang="es">
|
|
<video-player>
|
|
<video-skin>
|
|
<video src="..." playsinline></video>
|
|
</video-skin>
|
|
</video-player>
|
|
</media-i18n-provider>
|
|
</html>
|
|
```
|
|
|
|
The element reads from the registry for whichever `lang` is active. No changes to `<video-skin>` or any other skin element.
|
|
|
|
**CDN**
|
|
|
|
Locale files are self-registering ES modules. They import `registerI18n` from the same CDN module URL, sharing the same registry instance — no global namespace pollution:
|
|
|
|
```html
|
|
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/video.js"></script>
|
|
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/locales/es.js"></script>
|
|
|
|
<html lang="es">
|
|
<video-player>
|
|
<media-i18n-provider>
|
|
<video-skin>
|
|
<video src="..." playsinline></video>
|
|
</video-skin>
|
|
</media-i18n-provider>
|
|
</video-player>
|
|
</html>
|
|
```
|
|
|
|
```js
|
|
// es.js — self-registering, no window touch
|
|
import { registerI18n } from 'https://cdn.jsdelivr.net/npm/@videojs/html/cdn/i18n.js';
|
|
registerI18n('es', { play: 'Reproducir', pause: 'Pausa', /* … */ });
|
|
```
|
|
|
|
**Custom registration**
|
|
|
|
```ts
|
|
// Override individual keys
|
|
registerI18n('es', { play: 'Comenzar' });
|
|
|
|
// Or override a built-in pack entirely
|
|
import myEs from './my-es';
|
|
registerI18n('es', myEs);
|
|
```
|
|
|
|
### React
|
|
|
|
`I18nProvider` is a standalone component. Consumers add it explicitly inside the player's `Provider` — the skin itself has no i18n awareness. When `locale` is omitted, it reads from the nearest ancestor `lang` attribute (typically `<html lang="…">`):
|
|
|
|
```tsx
|
|
import { I18nProvider } from '@videojs/react';
|
|
import { VideoSkin, Video } from '@videojs/react/video';
|
|
|
|
// locale inherited from <html lang="es">
|
|
<Provider>
|
|
<I18nProvider>
|
|
<VideoSkin>
|
|
<Video src="..." playsInline />
|
|
</VideoSkin>
|
|
</I18nProvider>
|
|
</Provider>
|
|
```
|
|
|
|
Pass `locale` explicitly to override inheritance, or `translations` to bypass the registry entirely:
|
|
|
|
```tsx
|
|
import es from '@videojs/react/i18n/locales/es';
|
|
|
|
<Provider>
|
|
<I18nProvider locale="es" translations={es}>
|
|
<VideoSkin>
|
|
<Video src="..." playsInline />
|
|
</VideoSkin>
|
|
</I18nProvider>
|
|
</Provider>
|
|
```
|
|
|
|
**SSR / no flash-of-English** — pass `translations` directly on first render:
|
|
|
|
```tsx
|
|
// Server component
|
|
const { default: translations } = await import(`@videojs/react/i18n/locales/${locale}`);
|
|
|
|
<Provider>
|
|
<I18nProvider locale={locale} translations={translations}>
|
|
<VideoSkin>
|
|
<Video src="..." playsInline />
|
|
</VideoSkin>
|
|
</I18nProvider>
|
|
</Provider>
|
|
```
|
|
|
|
**Dynamic switching**
|
|
|
|
Just flip `locale` — the provider lazy-loads the built-in pack for the new locale:
|
|
|
|
```tsx
|
|
const [locale, setLocale] = useState('en');
|
|
|
|
<Provider>
|
|
<I18nProvider locale={locale}>
|
|
<VideoSkin>
|
|
<Video src="..." playsInline />
|
|
</VideoSkin>
|
|
</I18nProvider>
|
|
</Provider>
|
|
```
|
|
|
|
Or for zero-flash switching, pre-import the locale and pass `translations` directly:
|
|
|
|
```tsx
|
|
const [{ locale, translations }, setLocale] = useState({ locale: 'en', translations: undefined });
|
|
|
|
async function switchTo(next: string) {
|
|
const { default: translations } = await import(`@videojs/react/i18n/locales/${next}`);
|
|
setLocale({ locale: next, translations });
|
|
}
|
|
```
|
|
|
|
**With next-intl**
|
|
|
|
```tsx
|
|
import { getLocale } from 'next-intl/server';
|
|
|
|
const locale = await getLocale();
|
|
const { default: translations } = await import(`@videojs/react/i18n/locales/${locale}`);
|
|
|
|
<Provider>
|
|
<I18nProvider locale={locale} translations={translations}>
|
|
<VideoSkin>
|
|
<Video src="..." playsInline />
|
|
</VideoSkin>
|
|
</I18nProvider>
|
|
</Provider>
|
|
```
|
|
|
|
### Translation keys
|
|
|
|
Keys are opaque camelCase identifiers. The English string is the value in `en.ts` — not the key. All keys are optional; missing keys fall back to the English default.
|
|
|
|
| Key | English default | Params | Used by |
|
|
| --- | --------------- | ------ | ------- |
|
|
| `play` | `'Play'` | — | `PlayButtonCore` |
|
|
| `pause` | `'Pause'` | — | `PlayButtonCore` |
|
|
| `replay` | `'Replay'` | — | `PlayButtonCore` |
|
|
| `mute` | `'Mute'` | — | `MuteButtonCore` |
|
|
| `unmute` | `'Unmute'` | — | `MuteButtonCore` |
|
|
| `seek` | `'Seek'` | — | `TimeSliderCore` (aria-label) |
|
|
| `volume` | `'Volume'` | — | `VolumeSliderCore` (aria-label) |
|
|
| `muted` | `'muted'` | — | `VolumeSliderCore` (aria-valuetext suffix) |
|
|
| `enterFullscreen` | `'Enter fullscreen'` | — | `FullscreenButtonCore` |
|
|
| `exitFullscreen` | `'Exit fullscreen'` | — | `FullscreenButtonCore` |
|
|
| `enableCaptions` | `'Enable captions'` | — | `CaptionsButtonCore` |
|
|
| `disableCaptions` | `'Disable captions'` | — | `CaptionsButtonCore` |
|
|
| `enterPictureInPicture` | `'Enter picture-in-picture'` | — | `PipButtonCore` |
|
|
| `exitPictureInPicture` | `'Exit picture-in-picture'` | — | `PipButtonCore` |
|
|
| `currentTime` | `'Current time'` | — | `TimeCore` |
|
|
| `duration` | `'Duration'` | — | `TimeCore` |
|
|
| `remaining` | `'Remaining'` | — | `TimeCore` |
|
|
| `seekForward` | `'Seek forward {seconds} seconds'` | `{seconds}` | `SeekButtonCore` |
|
|
| `seekBackward` | `'Seek backward {seconds} seconds'` | `{seconds}` | `SeekButtonCore` |
|
|
| `playbackRate` | `'Playback rate {rate}'` | `{rate}` | `PlaybackRateButtonCore` |
|
|
| `timePosition` | `'{current} of {duration}'` | `{current}`, `{duration}` | `TimeSliderCore` (aria-valuetext) |
|
|
| `remaining` | `'remaining'` | — | `formatDuration` (negative time suffix) |
|
|
|
|
> `timePosition` params are already-formatted time phrases from `Intl.DurationFormat`, not raw numbers.
|
|
|
|
> `Intl.DurationFormat` handles all duration unit labels; `Intl.NumberFormat` handles percent formatting. Only `muted` and `remaining` are translation keys because `Intl` has no concept of those suffixes.
|
|
|
|
## Architecture
|
|
|
|
### Layers
|
|
|
|
```
|
|
@videojs/core/i18n
|
|
Translations · Translator · createTranslator
|
|
registerI18n · getI18nTranslations · onI18nRegistryChange
|
|
Registry (singleton Map) · en.ts (base layer, always present)
|
|
│
|
|
├── @videojs/react/i18n
|
|
│ createI18n() → { I18nContext, I18nProvider, useTranslator, useLocale }
|
|
│ Consumer wraps skin: <I18nProvider locale="es" translations={…}>
|
|
│
|
|
└── @videojs/html/i18n
|
|
createI18n() → { context, I18nController, ProviderMixin, TextMixin }
|
|
<media-i18n-provider lang="es"> = ProviderMixin(ReactiveElement)
|
|
<media-text key="play"> = TextMixin(ReactiveElement)
|
|
MediaButtonElement uses I18nController → t(core.getLabel(state))
|
|
|
|
@videojs/utils/i18n pluralize(count, forms, locale?)
|
|
@videojs/utils/time formatDuration(seconds, options?)
|
|
```
|
|
|
|
### Core types
|
|
|
|
```ts
|
|
// @videojs/core/i18n/types.ts
|
|
|
|
export type BuiltInLocale =
|
|
| 'ar' | 'de' | 'es' | 'fr' | 'it' | 'ja'
|
|
| 'ko' | 'nl' | 'pl' | 'pt' | 'ru' | 'tr' | 'zh';
|
|
|
|
/** Any BCP 47 tag; named built-in locales autocomplete in editors. */
|
|
export type Locale = BuiltInLocale | (string & {});
|
|
|
|
/** Helper — string type that must contain a given literal substring. */
|
|
type Contains<Needle extends string> = `${string}${Needle}${string}`;
|
|
|
|
/** Per-key parameter contract — maps each key to the exact params it requires (or `never`). */
|
|
export interface TranslationParams {
|
|
play: never;
|
|
pause: never;
|
|
replay: never;
|
|
mute: never;
|
|
unmute: never;
|
|
seek: never;
|
|
volume: never;
|
|
muted: never;
|
|
enterFullscreen: never;
|
|
exitFullscreen: never;
|
|
enableCaptions: never;
|
|
disableCaptions: never;
|
|
enterPictureInPicture: never;
|
|
exitPictureInPicture: never;
|
|
currentTime: never;
|
|
duration: never;
|
|
remaining: never;
|
|
seekForward: { seconds: string | number };
|
|
seekBackward: { seconds: string | number };
|
|
playbackRate: { rate: string | number };
|
|
timePosition: { current: string | number; duration: string | number };
|
|
}
|
|
|
|
/** All player translation keys. All keys are optional — missing keys fall back to English.
|
|
* Value types enforce the `{param}` placeholders required by `TranslationParams`. */
|
|
export interface Translations {
|
|
play?: string;
|
|
pause?: string;
|
|
replay?: string;
|
|
mute?: string;
|
|
unmute?: string;
|
|
seek?: string;
|
|
volume?: string;
|
|
muted?: string;
|
|
enterFullscreen?: string;
|
|
exitFullscreen?: string;
|
|
enableCaptions?: string;
|
|
disableCaptions?: string;
|
|
enterPictureInPicture?: string;
|
|
exitPictureInPicture?: string;
|
|
currentTime?: string;
|
|
duration?: string;
|
|
remaining?: string;
|
|
seekForward?: Contains<'{seconds}'>;
|
|
seekBackward?: Contains<'{seconds}'>;
|
|
playbackRate?: Contains<'{rate}'>;
|
|
timePosition?: Contains<'{current}'> & Contains<'{duration}'>;
|
|
}
|
|
|
|
type SimpleKeys = { [K in keyof TranslationParams]: TranslationParams[K] extends never ? K : never }[keyof TranslationParams];
|
|
type ParamKeys = Exclude<keyof TranslationParams, SimpleKeys>;
|
|
|
|
/** Callable translator. Keys with params require the exact params object; keys without params take no second argument. */
|
|
export type Translator = {
|
|
(key: SimpleKeys): string;
|
|
<K extends ParamKeys>(key: K, params: TranslationParams[K]): string;
|
|
readonly locale?: Locale;
|
|
};
|
|
```
|
|
|
|
The `Contains<Needle>` helper makes locale files compile-error when they forget a `{param}` placeholder. `Translator`'s overloaded signature catches two classes of mistake at call sites:
|
|
|
|
```ts
|
|
t('play'); // ✓ no params
|
|
t('play', { foo: 1 }); // ✗ TS error — no params accepted
|
|
t('seekForward'); // ✗ TS error — missing { seconds }
|
|
t('seekForward', { seconds: 10 }); // ✓
|
|
t('seekForward', { second: 10 }); // ✗ TS error — typo, wrong key
|
|
```
|
|
|
|
### Registry
|
|
|
|
```ts
|
|
// @videojs/core/i18n/registry.ts
|
|
const registry = new Map<string, Partial<Translations>>();
|
|
const subscribers = new Set<() => void>();
|
|
|
|
// en.ts is the base layer — pre-registered at module init
|
|
import en from './locales/en';
|
|
registry.set('en', en);
|
|
|
|
export function registerI18n(locale: string, translations: Partial<Translations>): void {
|
|
const existing = registry.get(locale) ?? {};
|
|
registry.set(locale, { ...existing, ...translations });
|
|
subscribers.forEach(fn => fn());
|
|
}
|
|
|
|
export function getI18nTranslations(locale: string): Partial<Translations> {
|
|
const en = registry.get('en') ?? {};
|
|
// Walk up the BCP 47 subtag chain: zh-Hant-HK → zh-Hant → zh → en
|
|
const parts = locale.split('-');
|
|
for (let i = parts.length; i > 0; i--) {
|
|
const tag = parts.slice(0, i).join('-');
|
|
const pack = registry.get(tag);
|
|
if (pack) return { ...en, ...pack };
|
|
}
|
|
return { ...en };
|
|
}
|
|
|
|
export function onI18nRegistryChange(callback: () => void): () => void {
|
|
subscribers.add(callback);
|
|
return () => subscribers.delete(callback);
|
|
}
|
|
```
|
|
|
|
`getI18nTranslations` always returns at least the English defaults (from `en.ts`). No key is ever missing — the per-key fallback is `registry[locale][key] → registry[parent-subtag][key] → … → en[key] → key`. See the [BCP 47 fallback](#bcp-47-fallback) section for the full subtag-truncation rules.
|
|
|
|
### `createTranslator`
|
|
|
|
```ts
|
|
export function createTranslator(
|
|
translations: Partial<Translations> = {},
|
|
locale?: Locale
|
|
): Translator {
|
|
// Overload signatures are type-only; implementation uses the loose callable form.
|
|
function t(key: keyof Translations, params?: Record<string, string | number>): string {
|
|
let value = (translations[key] ?? key) as string;
|
|
if (params) {
|
|
for (const [k, v] of Object.entries(params)) {
|
|
value = value.replace(`{${k}}`, String(v));
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
t.locale = locale;
|
|
return t as Translator;
|
|
}
|
|
```
|
|
|
|
### Locale files
|
|
|
|
Locale files are TypeScript source files. They use `satisfies Partial<Translations>` so typos in key names are caught at build time — a misspelled key is a compile error, not a silent runtime miss:
|
|
|
|
```ts
|
|
// locales/en.ts
|
|
import type { Translations } from '../types';
|
|
|
|
export default {
|
|
play: 'Play',
|
|
pause: 'Pause',
|
|
replay: 'Replay',
|
|
mute: 'Mute',
|
|
unmute: 'Unmute',
|
|
seek: 'Seek',
|
|
volume: 'Volume',
|
|
muted: 'muted',
|
|
enterFullscreen: 'Enter fullscreen',
|
|
exitFullscreen: 'Exit fullscreen',
|
|
enableCaptions: 'Enable captions',
|
|
disableCaptions: 'Disable captions',
|
|
enterPictureInPicture: 'Enter picture-in-picture',
|
|
exitPictureInPicture: 'Exit picture-in-picture',
|
|
currentTime: 'Current time',
|
|
duration: 'Duration',
|
|
remaining: 'Remaining',
|
|
seekForward: 'Seek forward {seconds} seconds',
|
|
seekBackward: 'Seek backward {seconds} seconds',
|
|
playbackRate: 'Playback rate {rate}',
|
|
timePosition: '{current} of {duration}',
|
|
} satisfies Partial<Translations>;
|
|
```
|
|
|
|
```ts
|
|
// locales/es.ts — partial locale, falls back to en.ts for missing keys
|
|
import type { Translations } from '../types';
|
|
|
|
export default {
|
|
// Play controls
|
|
play: 'Reproducir',
|
|
pause: 'Pausa',
|
|
replay: 'Repetir',
|
|
// Mute
|
|
mute: 'Silenciar',
|
|
unmute: 'Activar sonido',
|
|
// …
|
|
} satisfies Partial<Translations>;
|
|
```
|
|
|
|
The `satisfies` constraint checks key names and value types without widening the type — `keyof typeof es` remains the narrow set of keys present, not `keyof Translations`. Comments above groups of keys are useful context for translators.
|
|
|
|
For CDN, locale files are self-registering ES modules. The build emits a separate CDN entry point alongside the bundler-importable output:
|
|
|
|
```ts
|
|
// locales/es.cdn.ts — CDN entry, self-registers
|
|
import { registerI18n } from '../registry';
|
|
import translations from './es';
|
|
|
|
registerI18n('es', translations);
|
|
```
|
|
|
|
```js
|
|
// Built output: es.js (CDN)
|
|
import { registerI18n } from 'https://cdn.jsdelivr.net/npm/@videojs/html/cdn/i18n.js';
|
|
registerI18n('es', { play: 'Reproducir', pause: 'Pausa', /* … */ });
|
|
```
|
|
|
|
### React: `createI18n`
|
|
|
|
A factory that closes over a React context so `useTranslator()` returns `Translator` with no explicit type annotation at call sites. Called once in `@videojs/react/i18n`; the result is exported directly.
|
|
|
|
When `locale` is omitted, `I18nProvider` reads `document.documentElement.lang` and subscribes to changes via `MutationObserver`:
|
|
|
|
```tsx
|
|
export function createI18n() {
|
|
const I18nContext = createContext<Translator | null>(null);
|
|
|
|
function I18nProvider({ locale: explicitLocale, translations, children }: {
|
|
locale?: Locale;
|
|
translations?: Partial<Translations>;
|
|
children: ReactNode;
|
|
}) {
|
|
// Subscribe to ambient DOM lang attribute when no explicit prop is given
|
|
const ambientLocale = useSyncExternalStore(
|
|
subscribeToDocumentLang,
|
|
() => document.documentElement.lang || undefined,
|
|
() => undefined, // SSR: no ambient locale
|
|
);
|
|
const locale = explicitLocale ?? ambientLocale;
|
|
|
|
// Lazy-load built-in pack when locale is set
|
|
const [builtIn, setBuiltIn] = useState<Partial<Translations>>({});
|
|
useEffect(() => {
|
|
if (!locale) { setBuiltIn({}); return; }
|
|
const tags = [...new Set([locale, locale.split('-')[0]])];
|
|
const load = (i = 0): Promise<void> =>
|
|
import(`@videojs/core/i18n/locales/${tags[i]}`)
|
|
.then(m => { registerI18n(tags[i], m.default); setBuiltIn(m.default); })
|
|
.catch(() => i + 1 < tags.length ? load(i + 1) : setBuiltIn({}));
|
|
load();
|
|
}, [locale]);
|
|
|
|
// Browser Translation API — background fallback, pre-installed model only.
|
|
// Enumerates the full key set from en.ts (no consumer input needed).
|
|
const [browserTranslated, setBrowserTranslated] = useState<Partial<Translations>>({});
|
|
useEffect(() => {
|
|
if (!locale) { setBrowserTranslated({}); return; }
|
|
let cancelled = false;
|
|
getBrowserTranslations(locale).then(result => {
|
|
if (!cancelled) setBrowserTranslated(result);
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [locale]);
|
|
|
|
// Priority: browser API < registry/built-in < consumer translations
|
|
const translator = useMemo(
|
|
() => createTranslator(
|
|
{ ...browserTranslated, ...getI18nTranslations(locale ?? 'en'), ...builtIn, ...translations },
|
|
locale
|
|
),
|
|
[browserTranslated, builtIn, translations, locale]
|
|
);
|
|
|
|
return <I18nContext.Provider value={translator}>{children}</I18nContext.Provider>;
|
|
}
|
|
|
|
function useTranslator(): Translator {
|
|
return useContext(I18nContext) ?? createTranslator(getI18nTranslations('en'));
|
|
}
|
|
|
|
function useLocale(): Locale | undefined {
|
|
return useTranslator().locale;
|
|
}
|
|
|
|
return { I18nContext, I18nProvider, useTranslator, useLocale };
|
|
}
|
|
|
|
/** Shared MutationObserver subscription for document.documentElement.lang changes. */
|
|
function subscribeToDocumentLang(onChange: () => void): () => void {
|
|
const observer = new MutationObserver(onChange);
|
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] });
|
|
return () => observer.disconnect();
|
|
}
|
|
```
|
|
|
|
### HTML: `createI18n` factory
|
|
|
|
Parallel to React's `createI18n`, `createI18n` bundles the context, controller, provider element, and text element into one call. `@videojs/html/i18n` invokes it once and re-exports the result. Consumer-facing API is a single side-effect import that registers `<media-i18n-provider>` / `<media-text>`:
|
|
|
|
```ts
|
|
// @videojs/html/i18n/create-i18n.ts
|
|
|
|
export function createI18n() {
|
|
const context = createContext<Translator>(Symbol('@videojs/i18n'));
|
|
|
|
/** Controller for any element that consumes translated strings (e.g. button labels). */
|
|
class I18nController implements ReactiveController { /* subscribes to context */ }
|
|
|
|
/** Mixin: adds lang attribute + DOM lang resolution + ContextProvider for i18nContext. */
|
|
const ProviderMixin = <Base extends Constructor<ReactiveElement>>(base: Base) =>
|
|
class extends base { /* see "Provider mixin" below */ };
|
|
|
|
/** Mixin: adds key attribute + subscribes to i18nContext + writes textContent. */
|
|
const TextMixin = <Base extends Constructor<ReactiveElement>>(base: Base) =>
|
|
class extends base { /* see "Text mixin" below */ };
|
|
|
|
return { context, I18nController, ProviderMixin, TextMixin };
|
|
}
|
|
```
|
|
|
|
Mirrors `createPlayer` — the factory returns building blocks (context, controller, mixins). Define files compose them onto concrete bases and register the custom elements:
|
|
|
|
```ts
|
|
// @videojs/html/src/define/media-i18n-provider.ts
|
|
import { createI18n } from '@videojs/html/i18n';
|
|
|
|
const { ProviderMixin } = createI18n();
|
|
export class MediaI18nProviderElement extends ProviderMixin(ReactiveElement) {}
|
|
safeDefine('media-i18n-provider', MediaI18nProviderElement);
|
|
```
|
|
|
|
```ts
|
|
// @videojs/html/src/define/media-text.ts
|
|
import { createI18n } from '@videojs/html/i18n';
|
|
|
|
const { TextMixin } = createI18n();
|
|
export class MediaTextElement extends TextMixin(ReactiveElement) {}
|
|
safeDefine('media-text', MediaTextElement);
|
|
```
|
|
|
|
### HTML: Provider mixin
|
|
|
|
Reads from the registry, provides via `i18nContext`. Resolves `lang` from the element's own attribute if set, otherwise from the nearest ancestor's `lang` attribute (mirroring native HTML inheritance). Subscribes to both registry changes and DOM `lang` attribute mutations so live elements re-render when either changes:
|
|
|
|
```ts
|
|
const ProviderMixin = <Base extends Constructor<ReactiveElement>>(base: Base) =>
|
|
class extends base {
|
|
static override properties = { ...base.properties, lang: { type: String, reflect: true } };
|
|
|
|
/** Explicit override. When unset, resolves from DOM ancestor chain. */
|
|
lang: Locale | undefined = undefined;
|
|
|
|
readonly #provider = new ContextProvider(this, {
|
|
context,
|
|
initialValue: createTranslator(getI18nTranslations('en')),
|
|
});
|
|
|
|
#unsubscribeRegistry: (() => void) | null = null;
|
|
#langObserver: MutationObserver | null = null;
|
|
|
|
/** Resolve effective locale: explicit lang attribute → nearest ancestor[lang] → documentElement.lang → undefined. */
|
|
get #effectiveLocale(): Locale | undefined {
|
|
if (this.lang) return this.lang;
|
|
const ancestor = this.parentElement?.closest('[lang]');
|
|
return (ancestor?.getAttribute('lang') || document.documentElement.lang) || undefined;
|
|
}
|
|
|
|
override connectedCallback(): void {
|
|
super.connectedCallback();
|
|
this.#unsubscribeRegistry = onI18nRegistryChange(() => this.#refresh());
|
|
// Observe document-wide lang attribute changes so ambient updates re-render descendants.
|
|
this.#langObserver = new MutationObserver(() => this.#refresh());
|
|
this.#langObserver.observe(document.documentElement, {
|
|
subtree: true, attributes: true, attributeFilter: ['lang'],
|
|
});
|
|
this.#refresh();
|
|
}
|
|
|
|
override disconnectedCallback(): void {
|
|
super.disconnectedCallback();
|
|
this.#unsubscribeRegistry?.();
|
|
this.#langObserver?.disconnect();
|
|
this.#unsubscribeRegistry = this.#langObserver = null;
|
|
}
|
|
|
|
protected override updated(changed: PropertyValues): void {
|
|
super.updated(changed);
|
|
if (changed.has('lang')) this.#refresh();
|
|
}
|
|
|
|
async #refresh(): Promise<void> {
|
|
const locale = this.#effectiveLocale;
|
|
if (!locale) { this.#updateProvider(undefined); return; }
|
|
|
|
if (!hasRegisteredI18n(locale)) {
|
|
const tags = [...new Set([locale, locale.split('-')[0]])];
|
|
for (const tag of tags) {
|
|
try {
|
|
const { default: data } = await import(`@videojs/core/i18n/locales/${tag}`);
|
|
registerI18n(tag, data);
|
|
break;
|
|
} catch { /* try next tag */ }
|
|
}
|
|
}
|
|
|
|
// Browser Translation API — background, pre-installed model only
|
|
if (!hasRegisteredI18n(locale) && TRANSLATION_KEYS.length) {
|
|
getBrowserTranslations(locale, TRANSLATION_KEYS).then(result => {
|
|
if (Object.keys(result).length) registerI18n(locale, result);
|
|
});
|
|
}
|
|
|
|
this.#updateProvider(locale);
|
|
}
|
|
|
|
#updateProvider(locale: Locale | undefined): void {
|
|
this.#provider.setValue(createTranslator(getI18nTranslations(locale ?? 'en'), locale));
|
|
}
|
|
};
|
|
```
|
|
|
|
### HTML: Text mixin
|
|
|
|
Renders a translated string for a `key` attribute. Subscribes to `i18nContext`:
|
|
|
|
```ts
|
|
const TextMixin = <Base extends Constructor<ReactiveElement>>(base: Base) =>
|
|
class extends base {
|
|
static override properties = { ...base.properties, key: { type: String } };
|
|
|
|
key: string = '';
|
|
readonly #i18n = new I18nController(this);
|
|
|
|
protected override update(changed: PropertyValues): void {
|
|
super.update(changed);
|
|
this.textContent = this.#i18n.value(this.key);
|
|
}
|
|
};
|
|
```
|
|
|
|
`<media-text>` is for any dynamic translated content that isn't already wired to a component state (free-floating tooltip copy, dialog bodies, custom labels in ejected skins). Built-in button elements already auto-forward their `aria-label` from the registry via `I18nController` — no `<media-text>` wrapping required for those.
|
|
|
|
```html
|
|
<!-- Typical use: free-floating translated text in a skin template -->
|
|
<media-tooltip id="help">
|
|
<media-text key="seek"></media-text>
|
|
</media-tooltip>
|
|
```
|
|
|
|
### HTML: `I18nController` and `MediaButtonElement`
|
|
|
|
`I18nController` is returned from `createI18n()` alongside the mixins, closing over the same `context` symbol so it reads the value set by any `ProviderMixin`-backed element. Added to `MediaButtonElement` and `MediaUIElement` for auto-forwarded labels:
|
|
|
|
```ts
|
|
// Returned from createI18n() — closes over the factory's context
|
|
class I18nController implements ReactiveController {
|
|
readonly #consumer: ContextConsumer<typeof context, ReactiveElement>;
|
|
|
|
constructor(host: ReactiveElement) {
|
|
this.#consumer = new ContextConsumer(host, { context, subscribe: true });
|
|
host.addController(this);
|
|
}
|
|
|
|
get value(): Translator {
|
|
return this.#consumer.value ?? createTranslator(getI18nTranslations('en'));
|
|
}
|
|
|
|
hostConnected(): void {}
|
|
hostDisconnected(): void {}
|
|
}
|
|
```
|
|
|
|
```ts
|
|
// MediaButtonElement.update()
|
|
readonly #i18n = new I18nController(this);
|
|
|
|
protected override update(changed: PropertyValues): void {
|
|
super.update(changed);
|
|
const media = this.mediaState.value;
|
|
if (!media) return;
|
|
|
|
this.core.setMedia(media);
|
|
const state = this.core.getState();
|
|
const t = this.#i18n.value;
|
|
|
|
const key = this.core.getLabel(state); // opaque key, e.g. 'play'
|
|
const params = this.core.getLabelParams?.(state);
|
|
this.setAttribute('aria-label', t(key, params));
|
|
|
|
applyElementProps(this, this.core.getAttrs(state));
|
|
applyStateDataAttrs(this, state, this.stateAttrMap);
|
|
}
|
|
```
|
|
|
|
When no `<media-i18n-provider>` ancestor is present, `I18nController.value` falls back to a translator seeded from the English registry — English strings are rendered by default.
|
|
|
|
### Locale resolution
|
|
|
|
The provider resolves its active locale from the first source that yields a value:
|
|
|
|
```
|
|
1. Explicit locale prop (React) / lang attribute (HTML) on the provider
|
|
2. Nearest ancestor element with a [lang] attribute (HTML only; document.documentElement.lang in React)
|
|
3. undefined → English defaults
|
|
```
|
|
|
|
`<media-i18n-provider>` walks the DOM with `this.parentElement?.closest('[lang]')` and falls back to `document.documentElement.lang`. Both the explicit attribute and ambient sources are tracked — a `MutationObserver` watches `document.documentElement`'s subtree for `lang` attribute changes, so any update to `<html lang>` (or any intermediate wrapper's `lang`) re-renders descendants automatically.
|
|
|
|
React's `I18nProvider` uses `useSyncExternalStore` over `document.documentElement.lang` for the ambient value. SSR returns `undefined` from the server snapshot; on hydration the client reads the rendered `<html lang>` attribute, keeping server and client in sync.
|
|
|
|
This matches the inheritance semantics of the native HTML `lang` attribute — set it once at the page level and every player picks it up without extra wiring.
|
|
|
|
### BCP 47 fallback
|
|
|
|
Locale values are [BCP 47 language tags](https://datatracker.ietf.org/doc/html/rfc5646). The registry lookup in `getI18nTranslations(locale)` applies a left-truncation fallback — progressively dropping the rightmost subtag until a registered entry is found:
|
|
|
|
```
|
|
es-419-u-nu-latn → es-419 → es → en
|
|
zh-Hant-HK → zh-Hant → zh → en
|
|
pt-BR → pt-BR → pt → en
|
|
en-GB-scotland → en-GB-scotland → en-GB → en
|
|
sr-Latn → sr-Latn → sr → en
|
|
```
|
|
|
|
The algorithm treats each hyphen as a subtag boundary and truncates from the right. Script subtags (`Hant`, `Latn`), region subtags (`419`, `HK`, `GB`), and extension subtags (`u-nu-latn`) are all handled uniformly — no special cases.
|
|
|
|
**What it does *not* do.** Sibling fallback is not implemented: a request for `es-AR` will not fall back to `es-MX` even if only `es-MX` is registered. The chain is strictly up the parent hierarchy. Consumers who need sibling negotiation should register the base language (`es`) or the specific variants they want to cover.
|
|
|
|
`<media-i18n-provider>` applies the same fallback when lazy-loading built-in packs: it tries `pt-BR.ts`, then `pt.ts`, before giving up. Only full subtag matches succeed — `en-GB-scotland` requires a literal `en-GB-scotland.ts` file; otherwise it falls through to `en-GB` and finally `en`.
|
|
|
|
The Browser Translation API path uses the native `Intl.Locale` minimization where available for cross-referencing user preferences against the registered pack list.
|
|
|
|
### Browser Translation API
|
|
|
|
The [Translator API](https://developer.chrome.com/docs/ai/translator-api) (WICG draft, Chrome 138+ origin trial) provides on-device text translation. Video.js uses it as a background fallback for locales with no registered pack.
|
|
|
|
Only activates when `Translator.availability()` returns `'available'` — model already present, no network cost. `'downloadable'` / `'downloading'` / `'unavailable'` are silently skipped.
|
|
|
|
Since keys are opaque, the browser API translates the *English values* from `en.ts`, then maps the results back to keys:
|
|
|
|
```ts
|
|
const en = getI18nTranslations('en');
|
|
const englishValues = keys.map(k => en[k] ?? k);
|
|
const translated = await Promise.all(englishValues.map(v => translator.translate(v)));
|
|
return Object.fromEntries(keys.map((k, i) => [k, translated[i]]));
|
|
```
|
|
|
|
Results are cached by locale at module level — repeated mounts of the same skin do not re-trigger translation.
|
|
|
|
**Priority merge:**
|
|
|
|
```
|
|
English defaults (en.ts — always present)
|
|
↑
|
|
Browser API (auto-translated, background, pre-installed only)
|
|
↑
|
|
Registry pack (registerI18n / built-in locale)
|
|
↑
|
|
Consumer prop (translations — always wins)
|
|
```
|
|
|
|
### Intl API integration
|
|
|
|
**`Intl.DurationFormat`** — drives `formatDuration`. Handles unit labels, pluralization, and locale-specific ordering automatically (baseline: Chrome 122+, Firefox 127+, Safari 18+). The `remaining` suffix still uses `translate?.('remaining')` since `Intl.DurationFormat` has no concept of remaining time.
|
|
|
|
**`Intl.NumberFormat`** with `style: 'percent'` — formats volume values. No translation key needed.
|
|
|
|
**`Intl.PluralRules`** — powers the `pluralize` utility for skin authors who need locale-correct plurals in custom components:
|
|
|
|
```ts
|
|
import { pluralize } from '@videojs/utils/i18n';
|
|
|
|
const label = `${count} ${pluralize(count, { one: t('minute'), other: t('minutes') }, locale)}`;
|
|
```
|
|
|
|
### SSR & hydration safety
|
|
|
|
`Intl` APIs produce locale-specific output. If the active locale differs between server and client, hydration mismatches occur. The fix: always derive the locale from the same source on both sides (URL routing, cookie, or `Accept-Language` header passed explicitly).
|
|
|
|
```
|
|
HTTP request
|
|
→ read locale from cookie / URL / Accept-Language
|
|
→ set <html lang={locale}> on the server-rendered document
|
|
→ load translations
|
|
→ server render: <Provider><I18nProvider translations={t}>… (locale inherited from <html lang>)
|
|
→ client hydrate: <Provider><I18nProvider translations={t}>… ← identical = no mismatch
|
|
```
|
|
|
|
Passing `locale` explicitly to `I18nProvider` also works but is only required when overriding the document locale for a subtree. The ambient-locale pattern makes single-locale pages zero-config.
|
|
|
|
### CDN build strategy
|
|
|
|
The CDN bundle is an ES module. Locale files are separate self-registering ES modules — they import `registerI18n` from the same CDN URL, sharing the singleton registry instance with no global namespace touch:
|
|
|
|
```html
|
|
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/video.js"></script>
|
|
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/locales/es.js"></script>
|
|
```
|
|
|
|
```js
|
|
// https://cdn.jsdelivr.net/npm/@videojs/html/cdn/locales/es.js
|
|
import { registerI18n } from 'https://cdn.jsdelivr.net/npm/@videojs/html/cdn/i18n.js';
|
|
registerI18n('es', { play: 'Reproducir', pause: 'Pausa', /* … */ });
|
|
```
|
|
|
|
`<media-i18n-provider lang="es">` reads from the registry regardless of how data arrived — same element, same attribute, whether the translations came from an inline `registerI18n` call or a CDN locale module.
|
|
|
|
## Decisions
|
|
|
|
### Opaque camelCase keys, not English strings
|
|
|
|
**Decision.** Keys are camelCase identifiers (`play`, `seekForward`). The English string is the value in `en.ts`.
|
|
|
|
**Alternatives considered.**
|
|
|
|
- *English as key* — `t('Play')` falls back to `'Play'` automatically. Self-documenting. Renaming `'Play'` to `'Start'` in English breaks all other locale files. Established by VJS v7/8 and [Media Chrome](https://www.media-chrome.org/docs/en/internationalization/adding-language-support).
|
|
- *Numeric keys* — stable, compact, completely opaque. No useful fallback.
|
|
- *Namespaced paths* — `t('controls.play')`. Common in i18next; adds indirection.
|
|
|
|
**Rationale.** Stable keys decouple English UX copy from locale file versioning. Adding a new language or updating an English label are independent operations. The English string is still the default — it lives in `en.ts`, which is always pre-registered as the base layer. Consistent with i18next, FormatJS, iOS `NSLocalizedString`, and Android `strings.xml`.
|
|
|
|
### Global registry, not lazy-loading on skin element
|
|
|
|
**Decision.** A singleton `Map` in `@videojs/core/i18n`. `registerI18n(locale, translations)` is the imperative entry point. `<media-i18n-provider>` reads from it.
|
|
|
|
**Alternatives considered.**
|
|
|
|
- *JSON attribute on skin element* — `<video-skin translations='…'>`. Couples translations to the skin element; JSON attributes are fragile and lose type safety.
|
|
- *Side-effect locale imports* — `import '@videojs/core/i18n/locales/es'` auto-registers. Media Chrome uses this pattern (`import 'media-chrome/lang/es.js'`). Simple for CDN, but bundlers may include unwanted locales, and there's no explicit registration call for overrides.
|
|
- *Lazy-load only inside provider* — each provider independently loads its pack. Multiple players for the same locale each trigger a separate network request.
|
|
|
|
**Rationale.** Module-level singleton means each locale is loaded once per page regardless of how many players are mounted. For CDN, locale files are self-registering ES modules — they import `registerI18n` from the same module URL, sharing the registry instance with no global namespace touch. For bundlers, consumers call `registerI18n` explicitly after importing the JSON. `<media-i18n-provider>` can still lazy-load built-in packs as a convenience fallback (triggered only when the locale is not already registered).
|
|
|
|
### Standalone providers — skins are i18n-unaware
|
|
|
|
**Decision.** Neither HTML skins nor React skins own the i18n provider. Consumers add `<media-i18n-provider>` or `<I18nProvider>` explicitly and place them wherever they need.
|
|
|
|
**Alternatives considered.**
|
|
|
|
- *`I18nMixin` on the skin element* — skin element owns `translations` and `lang` attributes. Simpler consumer API but couples skins to i18n. Adding `lang` to `<video-skin>` creates an attribute collision with the native HTML `lang` attribute.
|
|
- *`locale`/`translations` props on `<VideoSkin>`* — skin internally wraps itself with provider. Zero friction, but skin must know about i18n; harder to share a provider across multiple skins.
|
|
|
|
**Rationale.** Skins are presentation layers. i18n is a cross-cutting concern. Decoupling follows the same principle as `<Provider>` for the store — skins consume context, providers supply it, and neither owns the other. Consumers can wrap multiple skins in one provider, or give each its own. The HTML `lang` attribute conflict disappears since `<media-i18n-provider lang="es">` is a distinct element.
|
|
|
|
### Provider inherits locale from the DOM
|
|
|
|
**Decision.** When no explicit `locale` prop (React) / `lang` attribute (HTML) is set on the provider, it resolves the locale from the nearest ancestor `[lang]` attribute (HTML) or `document.documentElement.lang` (React). A `MutationObserver` watches for changes so updates to `<html lang>` propagate automatically.
|
|
|
|
**Alternatives considered.**
|
|
|
|
- *Explicit locale always required on the provider* — previous revision behavior. Ergonomic friction: every app must pass the locale into the provider even when `<html lang>` already declares the page language.
|
|
- *Skin reads DOM `lang` directly, no provider* — would eliminate the provider for simple cases, but the provider is needed anyway for context distribution, registry subscription, and inline `translations`.
|
|
- *Read `navigator.language`* — reflects the user's browser preference, not the page's declared language. Can be wrong (e.g., a Spanish-language page viewed by a French user).
|
|
|
|
**Rationale.** The HTML `lang` attribute is the standard mechanism for declaring content language — search engines, screen readers, and spell-checkers already read it. Making the i18n provider honor the same attribute means zero extra config for the common case: set `<html lang="es">` once and every player on the page uses Spanish. The provider remains explicit and required; only its locale input becomes optional. React uses `locale` as the prop name (aligning with `Intl` and the rest of the TypeScript surface) while HTML uses the native `lang` attribute.
|
|
|
|
### `createI18n()` factory, not direct exports
|
|
|
|
**Decision.** A factory that closes over a React context. Returns `{ I18nContext, I18nProvider, useTranslator, useLocale }`. Called once in `@videojs/react/i18n` and re-exported.
|
|
|
|
**Alternatives considered.**
|
|
|
|
- *Direct module-level exports* — `I18nProvider` and `useTranslator` exported without a factory. Works but commits to a single context instance, preventing independent provider trees.
|
|
- *`useTranslator()` with call-site generic* — verbose; every component needs an explicit type argument.
|
|
|
|
**Rationale.** The factory pattern allows consumers to create independent provider trees if needed (e.g., two players with different languages and fully isolated contexts). It also keeps the provider and hooks as a cohesive unit — impossible to import `useTranslator` without the matching context.
|
|
|
|
### `<media-text>` for template strings
|
|
|
|
**Decision.** A `<media-text key="play">` element renders translated text inside shadow DOM templates.
|
|
|
|
**Alternatives considered.**
|
|
|
|
- *`<media-tooltip label="play">` self-translating* — tooltip knows its translation key. Couples tooltip semantics to i18n; translation key becomes a tooltip API concern.
|
|
- *Skin re-renders full template on locale change* — not viable; shadow DOM templates are static HTML cloned once on element creation.
|
|
|
|
**Rationale.** `<media-text>` is the minimal reactive primitive for translated text inside static shadow DOM. It subscribes to `i18nContext` independently and updates only `textContent` — no parent re-render needed. The `key` attribute is the only i18n contract in ejected skin templates, making them easy to audit and override.
|
|
|
|
### `{param}` interpolation, not ICU message format
|
|
|
|
**Decision.** Simple `{key}` replacement, as in `t('seekForward', { seconds: 10 })`.
|
|
|
|
**Rationale.** The player has a small, known set of interpolated strings; none require plural rules at the interpolation site. ICU format requires a runtime library (~20 KB). `{key}` replacement adds zero runtime weight and is established in VJS v8/9.
|
|
|
|
### Native `Intl` APIs for locale-aware formatting
|
|
|
|
**Decision.** `Intl.DurationFormat` for time phrases, `Intl.NumberFormat` with `style: 'percent'` for volume, and `Intl.PluralRules` via `pluralize` for plural selection.
|
|
|
|
**Rationale.** `Intl.DurationFormat` became baseline in 2024 and handles unit labels, pluralization, and locale-specific ordering — zero translation keys. `Intl.NumberFormat` handles percent symbols and digit forms. Only `remaining` and `muted` need translation keys because `Intl` has no concept of those suffixes.
|
|
|
|
### Browser Translation API — pre-installed model only
|
|
|
|
**Decision.** Only activate when `Translator.availability()` returns `'available'`. Skip silently for all other states.
|
|
|
|
**Rationale.** `'downloadable'` triggers a ~100 MB background download without consumer awareness. `'available'` means the model is already on-device — zero cost, free bonus. The feature is not reliable enough to be the primary translation mechanism; built-in locale packs are.
|
|
|
|
## Prior Art
|
|
|
|
| System | Key type | Distribution | Registry? |
|
|
| --- | --- | --- | --- |
|
|
| VJS v7/8 | English string | Built-in JSON + consumer | No — set via `videojs.addLanguage` |
|
|
| i18next | Opaque string | Consumer JSON | Yes — `i18next.addResourceBundle` |
|
|
| FormatJS / react-intl | Opaque string | Consumer JSON | No — prop-based |
|
|
| GNU gettext | English string | `.po` files | No |
|
|
| Android `strings.xml` | Opaque XML ID | Resource files | No |
|
|
| iOS `NSLocalizedString` | Opaque string | `.strings` files | No |
|
|
| Media Chrome | English string | Side-effect `import "media-chrome/lang/es.js"` | Yes — auto-registered on import; `lang` attribute on `<media-controller>` |
|
|
|
|
VJS v8's `addLanguage` is the closest precedent. This design replaces English-as-key (VJS v7/8) with opaque keys (consistent with i18next/Android/iOS) while keeping the imperative registration API.
|
|
|
|
## Edge Cases
|
|
|
|
**Multiple players, different languages.** Each `<media-i18n-provider>` or `<I18nProvider>` scopes its locale independently. The registry holds all registered locales simultaneously — providers read from it without interfering with each other. An explicit `locale` prop (React) or `lang` attribute (HTML) on the provider overrides any ambient `<html lang>`.
|
|
|
|
**`registerI18n` called after element mounts.** `<media-i18n-provider>` subscribes to `onI18nRegistryChange`. Calling `registerI18n` after mount triggers a re-render of all descendant elements.
|
|
|
|
**`<html lang>` changed at runtime.** Both HTML and React providers observe `document.documentElement` via `MutationObserver`. Dynamic locale switching works without remounting — update `document.documentElement.lang` and all providers without an explicit override re-render.
|
|
|
|
**SSR flash-of-English.** React `I18nProvider` starts with `builtIn: {}` — first render is English unless `translations` is passed. Import the locale module directly and pass as `translations` for zero flash (see SSR section above).
|
|
|
|
**`en.ts` bundle footprint.** `en.ts` is imported as a module-level side effect of `@videojs/core/i18n`. Consumers who never use i18n should not import this module — they will not pay the cost if they use only `@videojs/core/ui` or similar.
|
|
|
|
**Tooltip keys in ejected skins.** `<media-text key="play">` inside `<media-tooltip>` is unusual markup compared to hardcoded text. This is an acceptable tradeoff — the `key` attribute makes the translation contract explicit and the element is thin enough to not be confusing.
|
|
|
|
## Descoped
|
|
|
|
- **Right-to-left layout** — RTL support (mirroring, bidirectional text) is a separate concern from string translation and is not covered here.
|
|
- **Plural forms in translation strings** — e.g., `{count, plural, one {# result} other {# results}}`. ICU format is not supported. Skin authors who need locale-correct plurals use the `pluralize` utility from `@videojs/utils/i18n`.
|
|
- **Locale negotiation** — determining which locale to use from `Accept-Language`, cookies, or user preference is left to the consumer. Video.js accepts an explicit locale value.
|
|
- **Caption auto-selection by locale** — matching a `textTrack` against the active locale and toggling it on is a separate concern (application vs. user vs. stream-default language preferences all overlap here). Can be revisited as its own feature.
|
|
- **Translation memory / CAT tools** — locale files are TypeScript source; a compile step to JSON for CAT tooling compatibility is not provided.
|
|
- **Translator perf optimizations** — `createTranslator` re-parses `{param}` templates on every call via `String.prototype.replace`. Future work, not required for v1:
|
|
- *Run-time* — parse each template once on first use, cache tokens keyed by source string, reuse thereafter (similar to i18next's format cache).
|
|
- *Build-time* — AOT-compile locale entries into functions (`seekForward: ({ seconds }) => \`Seek forward ${seconds} seconds\``); inline static keys with dead-code elimination for the active locale (similar to`babel-plugin-formatjs` / `lingui`).
|
|
- **Custom translator implementations** — swapping `createTranslator` for a consumer-supplied translator (e.g., to delegate to `i18next` or FormatJS). The current `Translator` type is a callable, so this is feasible, but no public extension point is wired up.
|
|
|
|
## File Structure
|
|
|
|
```
|
|
packages/
|
|
├── core/src/i18n/
|
|
│ ├── types.ts ← BuiltInLocale, Locale, Translations, Translator
|
|
│ ├── translator.ts ← createTranslator
|
|
│ ├── registry.ts ← registerI18n, getI18nTranslations, onI18nRegistryChange
|
|
│ ├── index.ts ← re-exports
|
|
│ └── locales/
|
|
│ ├── en.ts ← English defaults (pre-registered at module init)
|
|
│ ├── ar.ts … zh.ts ← built-in locale packs
|
|
│ └── *.cdn.ts ← CDN self-registering entry points (one per locale)
|
|
│
|
|
├── react/src/i18n/
|
|
│ ├── create-i18n.tsx ← createI18n
|
|
│ ├── browser-translation.ts
|
|
│ ├── locales/ ← re-exports of core/locales/*.ts
|
|
│ └── index.ts ← public entry: registerI18n, I18nProvider, useTranslator, useLocale, Translations, Translator, Locale
|
|
│
|
|
└── html/src/i18n/
|
|
├── create-i18n.ts ← createI18n → { context, I18nController, ProviderMixin, TextMixin }
|
|
├── browser-translation.ts
|
|
├── locales/ ← re-exports of core/locales/*.ts
|
|
├── define/
|
|
│ ├── media-i18n-provider.ts ← ProviderMixin(ReactiveElement) + customElements.define
|
|
│ └── media-text.ts ← TextMixin(ReactiveElement) + customElements.define
|
|
└── index.ts ← public entry: registerI18n, Translations, Translator, Locale (side-effect registers elements)
|
|
|
|
packages/utils/src/i18n/
|
|
└── pluralize.ts
|
|
|
|
packages/utils/src/time/
|
|
└── format.ts ← formatDuration, TimeFormatOptions, TimeTranslate
|
|
```
|
|
|
|
### Modified files
|
|
|
|
| File | Change |
|
|
| ---- | ------ |
|
|
| `packages/core/src/core/ui/seek-button/seek-button-core.ts` | `getLabel` returns `'seekForward'`/`'seekBackward'`; add `getLabelParams` |
|
|
| `packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts` | Same |
|
|
| `packages/utils/src/time/format.ts` | Add `TimeTranslate`, `TimeFormatOptions`, optional `translate` param |
|
|
| `packages/html/src/ui/media-button-element.ts` | Add `I18nController`; apply `t()` in `update()` |
|
|
| `packages/html/src/define/video/skin.ts` | Replace hardcoded tooltip strings with `<media-text key="…">` children |
|
|
| `packages/react/src/i18n/index.ts` | Export `{ I18nProvider, useTranslator, useLocale }` from `createI18n()` |
|