mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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:
co-authored by
Cursor
Wesley Luyten
parent
fa768da757
commit
768bf09da0
+154
-175
@@ -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()` |
|
||||
|
||||
Reference in New Issue
Block a user