feat(core): add i18n foundation with English locale and UI wiring (#1589)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Wesley Luyten <me@wesleyluyten.com>
This commit is contained in:
Sam Potts
2026-06-18 11:55:38 -07:00
committed by GitHub
co-authored by Cursor Wesley Luyten
parent fa768da757
commit 768bf09da0
79 changed files with 2237 additions and 455 deletions
+154 -175
View File
@@ -1,5 +1,5 @@
---
status: draft
status: implemented
date: 2026-03-25
---
@@ -162,10 +162,10 @@ const { default: translations } = await import(`@videojs/react/i18n/locales/${lo
**Dynamic switching**
Just flip `locale` — the provider lazy-loads the built-in pack for the new locale:
Changing locale loads the shipped pack automatically via `loadLocale` (or pass `translations` / call `registerI18n` for zero-flash SSR):
```tsx
const [locale, setLocale] = useState('en');
const [locale, setLocale] = useState('es');
<Provider>
<I18nProvider locale={locale}>
@@ -176,7 +176,7 @@ const [locale, setLocale] = useState('en');
</Provider>
```
Or for zero-flash switching, pre-import the locale and pass `translations` directly:
For zero-flash switching, pre-import the locale and pass `translations` directly:
```tsx
const [{ locale, translations }, setLocale] = useState({ locale: 'en', translations: undefined });
@@ -215,27 +215,55 @@ Keys are opaque camelCase identifiers. The English string is the value in `en.ts
| `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) |
| `seekForward` | `'Seek forward {seconds} seconds'` | `{seconds}` | `SeekButtonCore` |
| `seekBackward` | `'Seek backward {seconds} seconds'` | `{seconds}` | `SeekButtonCore` |
| `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) |
| `playingLive` | `'Playing live'` | — | `LiveButtonCore` |
| `seekToLiveEdge` | `'Seek to live edge'` | — | `LiveButtonCore` |
| `liveBadge` | `'Live'` | — | Live badge / time display |
| `startCasting` | `'Start casting'` | — | `CastButtonCore` |
| `stopCasting` | `'Stop casting'` | — | `CastButtonCore` |
| `connectingCast` | `'Connecting'` | — | `CastButtonCore` |
| `seek` | `'Seek'` | — | `TimeSliderCore` (aria-label) |
| `volume` | `'Volume'` | — | `VolumeSliderCore` (aria-label) |
| `timeCurrent` | `'Current time'` | — | `TimeCore` |
| `timeDuration` | `'Duration'` | — | `TimeCore` |
| `timeRemaining` | `'Remaining'` | — | `TimeCore` |
| `remainingTimeSuffix` | `'remaining'` | — | `formatDuration` (negative time suffix) |
| `playbackRateAria` | `'Playback rate {rate}'` | `{rate}` | `PlaybackRateButtonCore`, playback-rate menu |
| `timeSliderValueTextRange` | `'{current} of {duration}'` | `{current}`, `{duration}` | `TimeSliderCore` (aria-valuetext) |
| `volumeSliderValueTextMuted` | `'{percent}, muted'` | `{percent}` | `VolumeSliderCore` (aria-valuetext when muted) |
| `indicatorMuted` | `'Muted'` | — | Input feedback (status / announcer) |
| `indicatorVolume` | `'Volume'` | — | Input feedback (status indicator label) |
| `indicatorVolumeWithValue` | `'Volume {value}'` | `{value}` | Input feedback (status announcer) |
| `indicatorCaptionsOn` | `'Captions on'` | — | Input feedback |
| `indicatorCaptionsOff` | `'Captions off'` | — | Input feedback |
| `indicatorPaused` | `'Paused'` | — | Input feedback |
| `indicatorPlaying` | `'Playing'` | — | Input feedback |
| `indicatorFullscreen` | `'Enter fullscreen'` | — | Input feedback |
| `indicatorExitFullscreen` | `'Exit fullscreen'` | — | Input feedback |
| `indicatorPictureInPicture` | `'Picture in picture'` | — | Input feedback |
| `indicatorExitPictureInPicture` | `'Exit picture in picture'` | — | Input feedback |
| `mediaErrorAborted` | `'You aborted the media playback'` | — | Error dialog |
| `mediaErrorNetwork` | `'A network error caused…'` | — | Error dialog |
| `mediaErrorDecode` | `'A media error caused playback…'` | — | Error dialog |
| `mediaErrorSrcNotSupported` | `'An unsupported error occurred…'` | — | Error dialog |
| `mediaErrorEncrypted` | `'The media is encrypted…'` | — | Error dialog |
| `mediaErrorCustom` | `''` | — | Error dialog (custom errors use literal message) |
| `errorDialogTitle` | `'Something went wrong.'` | — | Error dialog |
| `errorDialogDismiss` | `'OK'` | — | Error dialog |
| `mediaErrorFallback` | `'An error occurred. Please try again.'` | — | Error dialog |
> `timePosition` params are already-formatted time phrases from `Intl.DurationFormat`, not raw numbers.
> `timeSliderValueTextRange` 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.
> `Intl.DurationFormat` handles duration unit labels; `Intl.NumberFormat` handles percent formatting. Only `remainingTimeSuffix` and `volumeSliderValueTextMuted` need translation keys for suffixes `Intl` cannot express.
> Full key list and param contracts: `packages/core/src/core/i18n/types.ts` (`TranslationParams`).
## Architecture
@@ -263,82 +291,15 @@ Keys are opaque camelCase identifiers. The English string is the value in `en.ts
### Core types
```ts
// @videojs/core/i18n/types.ts
Authoritative definitions: `packages/core/src/core/i18n/types.ts` and `built-in-locales.ts`.
export type BuiltInLocale =
| 'ar' | 'de' | 'es' | 'fr' | 'it' | 'ja'
| 'ko' | 'nl' | 'pl' | 'pt' | 'ru' | 'tr' | 'zh';
- **`BuiltInLocale`** — autocomplete for shipped packs (`BUILT_IN_LOCALES`: 50 tags + `LOCALE_ALIAS_TAGS`: `pt`, `zh`).
- **`Locale`** — `BuiltInLocale | (string & {})` for any BCP 47 tag.
- **`TranslationParams`** — per-key param contract (`never` = no params).
- **`Translations`** — optional overlay; parametric values must contain required `{placeholder}` substrings (`Contains<>` helper).
- **`Translator`** — typed `(key, params?) => string` from `createTranslator`.
/** 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:
The `Contains<Needle>` helper makes locale files compile-error when they forget a `{param}` placeholder. `Translator`'s signature catches two classes of mistake at call sites:
```ts
t('play'); // ✓ no params
@@ -497,38 +458,28 @@ export function createI18n() {
);
const locale = explicitLocale ?? ambientLocale;
// Lazy-load built-in pack when locale is set
const [builtIn, setBuiltIn] = useState<Partial<Translations>>({});
// Lazy-load built-in overlays, then browser fallback when no pack exists.
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]);
const seq = ++lazySeqRef.current;
const locale = resolvedLocale;
void (async () => {
const mergedLazy = await mergeLocaleOverlays(locale, loadLocale, localeLookupChain);
if (seq !== lazySeqRef.current) return;
if (shouldAttemptBrowserTranslation(locale, mergedLazy)) {
const browser = await getBrowserTranslations(locale);
if (Object.keys(browser).length) registerI18n(locale, browser);
}
if (seq !== lazySeqRef.current) return;
setLazyLayer(mergedLazy);
})();
}, [resolvedLocale]);
// 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]
// Priority: registry (incl. browser registerI18n) < lazy built-in < consumer translations
const translations = useMemo(
() => ({ ...getI18nTranslations(resolvedLocale), ...lazyLayer, ...translationsProp }),
[resolvedLocale, lazyLayer, translationsProp, registryEpoch]
);
const translator = useMemo(() => createTranslator(translations, resolvedLocale), [translations, resolvedLocale]);
return <I18nContext.Provider value={translator}>{children}</I18nContext.Provider>;
}
@@ -647,33 +598,29 @@ const ProviderMixin = <Base extends Constructor<ReactiveElement>>(base: Base) =>
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 */ }
async #resetLazyAndLoad(): Promise<void> {
const localeSnapshot = resolveProviderLocale(this);
this.#lazyResetStartedForLocale = localeSnapshot;
this.#lazySeq += 1;
const seq = this.#lazySeq;
this.#lazyLayer = {};
void (async () => {
const merged = await mergeLocaleOverlays(localeSnapshot, loadLocale, localeLookupChain);
if (seq !== this.#lazySeq) return;
if (shouldAttemptBrowserTranslation(localeSnapshot, merged)) {
const browser = await getBrowserTranslations(localeSnapshot);
if (Object.keys(browser).length) registerI18n(localeSnapshot, browser);
}
}
// 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);
if (seq !== this.#lazySeq) return;
this.#lazyLayer = merged;
this.requestUpdate();
})();
}
#updateProvider(locale: Locale | undefined): void {
this.#provider.setValue(createTranslator(getI18nTranslations(locale ?? 'en'), locale));
#publish(): void {
const locale = resolveProviderLocale(this);
const translations = { ...getI18nTranslations(locale), ...this.#lazyLayer };
this.#i18nProvider.setValue({ translator: createTranslator(translations, locale), locale });
}
};
```
@@ -697,10 +644,24 @@ const TextMixin = <Base extends Constructor<ReactiveElement>>(base: Base) =>
};
```
`<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: Built-in skin tooltips (trigger sync)
Built-in skins use **empty** `<media-tooltip>` elements linked to controls via `commandfor`. Tooltip text is not authored in the skin template — `TooltipElement` copies the trigger button's translated label at runtime (`getLabel()` / `getResolvedLabel()` via `I18nController`), keeping tooltip copy and `aria-label` in sync.
```html
<!-- Typical use: free-floating translated text in a skin template -->
<!-- packages/html/src/define/video/skin.ts -->
<media-play-button commandfor="play-tooltip"></media-play-button>
<media-tooltip id="play-tooltip" side="top" class="media-surface media-tooltip"></media-tooltip>
```
Changing text inside `<media-tooltip>` in a built-in skin has no effect. To customize tooltip and `aria-label`, set `label` on the control (e.g. `<media-play-button label="play">`) or override the component's core label key.
### HTML: `<media-text>` (ejected / custom skins only)
`<media-text>` renders translated copy for static shadow DOM strings **not** owned by a component's `getLabel()` — standalone help text, dialog bodies, or tooltips in **ejected** skins without a linked trigger. Built-in button labels and built-in tooltips do not use `<media-text>`.
```html
<!-- Ejected skin: free-floating translated copy with no trigger button -->
<media-tooltip id="help">
<media-text key="seek"></media-text>
</media-tooltip>
@@ -791,36 +752,39 @@ The Browser Translation API path uses the native `Intl.Locale` minimization wher
### 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.
The [Translator API](https://developer.chrome.com/docs/ai/translator-api) (WICG draft, Chrome 138+) provides on-device text translation. Video.js uses it as a background fallback for locales with no registered or lazy-loaded pack. Implementation lives in `@videojs/core/i18n` (`getBrowserTranslations`, `shouldAttemptBrowserTranslation`).
Only activates when `Translator.availability()` returns `'available'` — model already present, no network cost. `'downloadable'` / `'downloading'` / `'unavailable'` are silently skipped.
Only activates when `Translator.availability()` returns `'available'` — model already present, no network cost. `'downloadable'` / `'downloading'` / `'unavailable'` are silently skipped in production providers.
Since keys are opaque, the browser API translates the *English values* from `en.ts`, then maps the results back to keys:
**Sandbox exception.** The dev sandbox may call `getBrowserTranslations` with `downloadIfNeeded: true` for browser-only locale tags (Chrome Translator API languages without a shipped pack). This is for demo coverage only — not production behavior.
```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]]));
```
Providers call `shouldAttemptBrowserTranslation(locale, lazyLayer)` after `mergeLocaleOverlays` — skip when locale is English, when lazy built-ins loaded, or when any non-`en` tag in `localeLookupChain(locale)` is already registered (covers `es-MX``es`).
Results are cached by locale at module level — repeated mounts of the same skin do not re-trigger translation.
Since keys are opaque, the browser API translates the *English values* from `en.ts`, then maps the results back to keys. Results are registered via `registerI18n` (triggering `onI18nRegistryChange`) and cached per target language at module level.
**Priority merge:**
```
English defaults (en.ts — always present)
Browser API (auto-translated, background, pre-installed only)
Browser API (registerI18n after async translate; pre-installed model only)
Registry pack (registerI18n / built-in locale)
Registry pack (registerI18n / CDN locale modules)
Consumer prop (translations — always wins)
Lazy built-in (`loadLocale` overlay from `@videojs/core/i18n`)
Consumer prop (React translations — always wins)
```
### `loadLocale`
`loadLocale(tag)` in `@videojs/core/i18n` lazy-imports shipped locale packs by exact BCP 47 tag. It skips tags already present in the registry (including `en`) so explicit `registerI18n` overrides are preserved. Default `createI18n()` providers call it via `mergeLocaleOverlays` when the locale changes — `<html lang="es">` or `<I18nProvider locale="es">` loads Spanish without a prior `registerI18n` call. Override with `createI18n({ loadLocale })` in tests or custom apps.
Explicit `registerI18n`, CDN locale modules, and React `translations` still merge on top and remain the preferred path for SSR (zero flash) and CDN.
### 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.DurationFormat`** — drives `formatDuration`. Handles unit labels, pluralization, and locale-specific ordering automatically (baseline: Chrome 122+, Firefox 127+, Safari 18+). The remaining-time suffix uses `remainingTimeSuffix` via `formatOptions.translate` since `Intl.DurationFormat` has no concept of remaining time.
**`Intl.NumberFormat`** with `style: 'percent'` — formats volume values. No translation key needed.
@@ -888,7 +852,7 @@ registerI18n('es', { play: 'Reproducir', pause: 'Pausa', /* … */ });
- *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).
**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 may call `registerI18n` explicitly or rely on default provider `loadLocale` lazy imports. Pass React `translations` for SSR zero-flash.
### Standalone providers — skins are i18n-unaware
@@ -924,16 +888,27 @@ registerI18n('es', { play: 'Reproducir', pause: 'Pausa', /* … */ });
**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
### Built-in tooltips: trigger sync, not `<media-text>`
**Decision.** A `<media-text key="play">` element renders translated text inside shadow DOM templates.
**Decision.** Shipped video/audio/live skins use empty `<media-tooltip>` + `commandfor`; `TooltipElement` syncs translated text from the linked control's label.
**Alternatives considered.**
- *`<media-text key="play">` inside each tooltip* — duplicates the control's translation key; two sources of truth for the same string.
- *`<media-tooltip label="play">` self-translating* — couples tooltip semantics to i18n; translation key becomes a tooltip API concern.
- *Hardcoded English in skin templates* — original v9 pattern; breaks locale switching.
**Rationale.** Control cores already expose opaque label keys; `MediaButtonElement` and `TooltipElement` both resolve through `I18nController`. One key → one translated string for `aria-label` and tooltip.
### `<media-text>` for ejected skin template strings
**Decision.** A `<media-text key="…">` element renders translated text inside shadow DOM templates where no component owns the phrase.
**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.
**Rationale.** `<media-text>` is the minimal reactive primitive for ad-hoc translated copy in custom/ejected skins. It subscribes to `i18nContext` independently and updates only `textContent` — no parent re-render needed.
### `{param}` interpolation, not ICU message format
@@ -945,7 +920,7 @@ registerI18n('es', { play: 'Reproducir', pause: 'Pausa', /* … */ });
**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.
**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 `remainingTimeSuffix` and `volumeSliderValueTextMuted` need translation keys because `Intl` has no concept of those suffixes.
### Browser Translation API — pre-installed model only
@@ -979,7 +954,7 @@ VJS v8's `addLanguage` is the closest precedent. This design replaces English-as
**`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.
**When to use `<media-text>`.** Prefer control `label` + trigger-synced tooltips for anything tied to a button. Use `<media-text>` only for standalone copy in ejected skins (help text, custom dialog bodies) where no component exposes `getLabel()`.
## Descoped
@@ -991,32 +966,33 @@ VJS v8's `addLanguage` is the closest precedent. This design replaces English-as
- **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.
- **Custom translator implementations** — a consumer-supplied `translate(key) → string` override (e.g. to delegate to `i18next` or FormatJS). Deferred follow-up; use `registerI18n` or React `translations` prop today.
## File Structure
```
packages/
├── core/src/i18n/
├── core/src/core/i18n/
│ ├── types.ts ← BuiltInLocale, Locale, Translations, Translator
│ ├── translator.ts ← createTranslator
│ ├── browser-translation.ts ← getBrowserTranslations, shouldAttemptBrowserTranslation
│ ├── load-locale.ts ← loadLocale (codegen lazy import map)
│ ├── 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
│ ├── ar.ts … zh.ts ← built-in locale packs (default export each)
│ ├── all.ts ← aggregated map `{ all, localeTags }` (generated; loads every pack)
│ └── *.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
│ ├── locales/ ← generated 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
├── locales/ ← generated re-exports of core/locales/*.ts
├── define/
│ ├── media-i18n-provider.ts ← ProviderMixin(ReactiveElement) + customElements.define
│ └── media-text.ts ← TextMixin(ReactiveElement) + customElements.define
@@ -1029,6 +1005,8 @@ packages/utils/src/time/
└── format.ts ← formatDuration, TimeFormatOptions, TimeTranslate
```
**Adding a locale:** author `packages/core/src/core/i18n/locales/{tag}.ts`, append the tag to `built-in-locales.ts`, then run `pnpm -F @videojs/core generate:locales` (also runs on `@videojs/core` prebuild). That regenerates `locales/all.ts` and html/react re-export stubs.
### Modified files
| File | Change |
@@ -1037,5 +1015,6 @@ packages/utils/src/time/
| `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/html/src/ui/tooltip/tooltip-element.ts` | Sync tooltip text from trigger `getResolvedLabel()` |
| `packages/core/src/core/i18n/browser-translation.ts` | Browser Translation API fallback + `shouldAttemptBrowserTranslation` |
| `packages/react/src/i18n/index.ts` | Export `{ I18nProvider, useTranslator, useLocale }` from `createI18n()` |