--- 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 ``) 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 `` reads from the nearest ancestor `lang` attribute, matching the inheritance model of the native HTML `lang` attribute. Setting `lang` on `` — or any ancestor — is enough: ```html ``` An explicit `lang` attribute on the provider overrides the inherited value — useful when a single page hosts players in different languages: ```html ``` The element reads from the registry for whichever `lang` is active. No changes to `` 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 ``` ```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 ``): ```tsx import { I18nProvider } from '@videojs/react'; import { VideoSkin, Video } from '@videojs/react/video'; // locale inherited from ``` Pass `locale` explicitly to override inheritance, or `translations` to bypass the registry entirely: ```tsx import es from '@videojs/react/i18n/locales/es'; ``` **SSR / no flash-of-English** — pass `translations` directly on first render: ```tsx // Server component const { default: translations } = await import(`@videojs/react/i18n/locales/${locale}`); ``` **Dynamic switching** Just flip `locale` — the provider lazy-loads the built-in pack for the new locale: ```tsx const [locale, setLocale] = useState('en'); ``` 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}`); ``` ### 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: │ └── @videojs/html/i18n createI18n() → { context, I18nController, ProviderMixin, TextMixin } = ProviderMixin(ReactiveElement) = 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 = `${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; /** Callable translator. Keys with params require the exact params object; keys without params take no second argument. */ export type Translator = { (key: SimpleKeys): string; (key: K, params: TranslationParams[K]): string; readonly locale?: Locale; }; ``` The `Contains` 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>(); 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): void { const existing = registry.get(locale) ?? {}; registry.set(locale, { ...existing, ...translations }); subscribers.forEach(fn => fn()); } export function getI18nTranslations(locale: string): Partial { 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 = {}, locale?: Locale ): Translator { // Overload signatures are type-only; implementation uses the loose callable form. function t(key: keyof Translations, params?: Record): 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` 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; ``` ```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; ``` 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(null); function I18nProvider({ locale: explicitLocale, translations, children }: { locale?: Locale; translations?: Partial; 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>({}); useEffect(() => { if (!locale) { setBuiltIn({}); return; } const tags = [...new Set([locale, locale.split('-')[0]])]; const load = (i = 0): Promise => 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>({}); 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 {children}; } 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 `` / ``: ```ts // @videojs/html/i18n/create-i18n.ts export function createI18n() { const context = createContext(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: Base) => class extends base { /* see "Provider mixin" below */ }; /** Mixin: adds key attribute + subscribes to i18nContext + writes textContent. */ const TextMixin = >(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: 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 { 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: 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); } }; ``` `` 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 `` wrapping required for those. ```html ``` ### 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; 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 `` 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 ``` `` 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 `` (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 `` 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. `` 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 on the server-rendered document → load translations → server render: … (locale inherited from ) → client hydrate: … ← 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 ``` ```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', /* … */ }); ``` `` 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. `` reads from it. **Alternatives considered.** - *JSON attribute on skin element* — ``. 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. `` 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 `` or `` 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 `` creates an attribute collision with the native HTML `lang` attribute. - *`locale`/`translations` props on ``* — 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 `` 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 `` 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 `` 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 `` 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 `` 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. ### `` for template strings **Decision.** A `` element renders translated text inside shadow DOM templates. **Alternatives considered.** - *`` 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.** `` 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 `` | 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 `` or `` 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 ``. **`registerI18n` called after element mounts.** `` subscribes to `onI18nRegistryChange`. Calling `registerI18n` after mount triggers a re-render of all descendant elements. **`` 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.** `` inside `` 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 `` children | | `packages/react/src/i18n/index.ts` | Export `{ I18nProvider, useTranslator, useLocale }` from `createI18n()` |