50 KiB
status, date
| status | date |
|---|---|
| implemented | 2026-03-25 |
Internationalization (i18n)
Opaque-key translation system with a global registry and ES module locale files for CDN, typed hooks for React.
Problem
All aria-label, aria-valuetext, tooltip labels, and time unit strings in Video.js 10 are hardcoded English. PlayButtonCore.getLabel() returns 'play'. formatDuration returns '2 minutes, 30 seconds'. There is no mechanism to supply translated strings without overriding each component's label prop individually, and no single language switch for skins.
Requirements:
- Single entry point — one API replaces all English defaults regardless of layer (HTML, React, CDN)
- Stable keys — renaming an English label must not require updating every locale file
- Typed keys — TypeScript autocomplete for all keys
- Decoupled — i18n providers are not embedded in skins; any skin works with any provider
- Works without skins — standalone VJS components (buttons, sliders, time elements used outside of
<video-skin>) can consume a provider directly - CDN-compatible — ES module locale files self-register with no bundler and no global namespace pollution
- No side-effect locale imports — bundlers don't accidentally include all languages
- Framework-agnostic core — the registry and translator live in
@videojs/core/i18n, re-exported to consumers as@videojs/html/i18nand@videojs/react/i18n - Locale-aware — native
IntlAPIs handle duration, number, and plural formatting
API
Registering translations
The single entry point for both HTML and React consumers:
// HTML
import { registerI18n } from '@videojs/html/i18n';
import es from '@videojs/html/i18n/locales/es';
// React
import { registerI18n } from '@videojs/react';
import es from '@videojs/react/i18n/locales/es';
registerI18n('es', es);
registerI18n merges (does not replace) — multiple calls for the same language are additive. English defaults (en.ts) are pre-registered as the base layer on module import.
HTML
<media-i18n> reads from the nearest ancestor lang attribute, matching the inheritance model of the native HTML lang attribute. Setting lang on <html> — or any ancestor — is enough:
<html lang="es">
<body>
<video-player>
<media-i18n>
<video-skin>
<video src="..." playsinline></video>
</video-skin>
</media-i18n>
</video-player>
</body>
</html>
An explicit lang attribute on the provider overrides the inherited value — useful when a single page hosts players in different languages:
<html lang="en">
<media-i18n lang="es">
<video-player>
<video-skin>
<video src="..." playsinline></video>
</video-skin>
</video-player>
</media-i18n>
</html>
The element reads from the registry for whichever lang is active. No changes to <video-skin> or any other skin element.
CDN
Locale files are self-registering ES modules. They import registerI18n from the same CDN module URL, sharing the same registry instance — no global namespace pollution:
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/video.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/locales/es.js"></script>
<html lang="es">
<video-player>
<media-i18n>
<video-skin>
<video src="..." playsinline></video>
</video-skin>
</media-i18n>
</video-player>
</html>
// 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
// Override individual keys
registerI18n('es', { play: 'Comenzar' });
// Or override a built-in pack entirely
import myEs from './my-es';
registerI18n('es', myEs);
React
I18nProvider is a standalone component. Consumers add it explicitly inside the player's Provider — the skin itself has no i18n awareness. When locale is omitted, it reads from the nearest ancestor lang attribute (typically <html lang="…">):
import { I18nProvider } from '@videojs/react';
import { VideoSkin, Video } from '@videojs/react/video';
// locale inherited from <html lang="es">
<Provider>
<I18nProvider>
<VideoSkin>
<Video src="..." playsInline />
</VideoSkin>
</I18nProvider>
</Provider>
Pass locale explicitly to override inheritance, or translations to bypass the registry entirely:
import es from '@videojs/react/i18n/locales/es';
<Provider>
<I18nProvider locale="es" translations={es}>
<VideoSkin>
<Video src="..." playsInline />
</VideoSkin>
</I18nProvider>
</Provider>
SSR / no flash-of-English — pass translations directly on first render:
// Server component
const { default: translations } = await import(`@videojs/react/i18n/locales/${locale}`);
<Provider>
<I18nProvider locale={locale} translations={translations}>
<VideoSkin>
<Video src="..." playsInline />
</VideoSkin>
</I18nProvider>
</Provider>
Dynamic switching
Changing locale loads the shipped pack automatically via loadLocale (or pass translations / call registerI18n for zero-flash SSR):
const [locale, setLocale] = useState('es');
<Provider>
<I18nProvider locale={locale}>
<VideoSkin>
<Video src="..." playsInline />
</VideoSkin>
</I18nProvider>
</Provider>
For zero-flash switching, pre-import the locale and pass translations directly:
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
import { getLocale } from 'next-intl/server';
const locale = await getLocale();
const { default: translations } = await import(`@videojs/react/i18n/locales/${locale}`);
<Provider>
<I18nProvider locale={locale} translations={translations}>
<VideoSkin>
<Video src="..." playsInline />
</VideoSkin>
</I18nProvider>
</Provider>
Translation keys
Keys are opaque camelCase identifiers. The English string is the value in en.ts — not the key. All keys are optional; missing keys fall back to the English default.
| Key | English default | Params | Used by |
|---|---|---|---|
play |
'Play' |
— | PlayButtonCore |
pause |
'Pause' |
— | PlayButtonCore |
replay |
'Replay' |
— | PlayButtonCore |
mute |
'Mute' |
— | MuteButtonCore |
unmute |
'Unmute' |
— | MuteButtonCore |
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 |
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 |
timeSliderValueTextRangeparams are already-formatted time phrases fromIntl.DurationFormat, not raw numbers.
Intl.DurationFormathandles duration unit labels;Intl.NumberFormathandles percent formatting. OnlyremainingTimeSuffixandvolumeSliderValueTextMutedneed translation keys for suffixesIntlcannot express.
Full key list and param contracts:
packages/core/src/core/i18n/types.ts(TranslationParams).
Architecture
Layers
@videojs/core/i18n
Translations · Translator · createTranslator
registerI18n · getI18nTranslations · onI18nRegistryChange
Registry (singleton Map) · en.ts (base layer, always present)
│
├── @videojs/react/i18n
│ createI18n() → { I18nContext, I18nProvider, useTranslator, useLocale }
│ Consumer wraps skin: <I18nProvider locale="es" translations={…}>
│
└── @videojs/html/i18n
createI18n() → { context, I18nController, ProviderMixin, TextMixin }
<media-i18n lang="es"> = ProviderMixin(ReactiveElement)
<media-text>Play</media-text> = TextMixin(ReactiveElement)
MediaButtonElement uses I18nController → t(core.getLabel(state))
@videojs/utils/i18n pluralize(count, forms, locale?)
@videojs/utils/time formatDuration(seconds, options?)
Core types
Authoritative definitions: packages/core/src/core/i18n/types.ts and locales.ts.
Locale—(typeof LOCALES)[number] | (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?) => stringfromcreateTranslator.
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:
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
// @videojs/core/i18n/registry.ts
const registry = new Map<string, Partial<Translations>>();
const subscribers = new Set<() => void>();
// en.ts is the base layer — pre-registered at module init
import en from './locales/en';
registry.set('en', en);
export function registerI18n(locale: string, translations: Partial<Translations>): void {
const existing = registry.get(locale) ?? {};
registry.set(locale, { ...existing, ...translations });
subscribers.forEach(fn => fn());
}
export function getI18nTranslations(locale: string): Partial<Translations> {
const en = registry.get('en') ?? {};
// Walk up the BCP 47 subtag chain: zh-Hant-HK → zh-Hant → zh → en
const parts = locale.split('-');
for (let i = parts.length; i > 0; i--) {
const tag = parts.slice(0, i).join('-');
const pack = registry.get(tag);
if (pack) return { ...en, ...pack };
}
return { ...en };
}
export function onI18nRegistryChange(callback: () => void): () => void {
subscribers.add(callback);
return () => subscribers.delete(callback);
}
getI18nTranslations always returns at least the English defaults (from en.ts). No key is ever missing — the per-key fallback is registry[locale][key] → registry[parent-subtag][key] → … → en[key] → key. See the BCP 47 fallback section for the full subtag-truncation rules.
createTranslator
export function createTranslator(
translations: Partial<Translations> = {},
locale?: Locale
): Translator {
// Overload signatures are type-only; implementation uses the loose callable form.
function t(key: keyof Translations, params?: Record<string, string | number>): string {
let value = (translations[key] ?? key) as string;
if (params) {
for (const [k, v] of Object.entries(params)) {
value = value.replace(`{${k}}`, String(v));
}
}
return value;
}
t.locale = locale;
return t as Translator;
}
Locale files
Locale files are TypeScript source files. They use satisfies Partial<Translations> so typos in key names are caught at build time — a misspelled key is a compile error, not a silent runtime miss:
// locales/en.ts
import type { Translations } from '../types';
export default {
play: 'Play',
pause: 'Pause',
replay: 'Replay',
mute: 'Mute',
unmute: 'Unmute',
seek: 'Seek',
volume: 'Volume',
muted: 'muted',
enterFullscreen: 'Enter fullscreen',
exitFullscreen: 'Exit fullscreen',
enableCaptions: 'Enable captions',
disableCaptions: 'Disable captions',
enterPictureInPicture: 'Enter picture-in-picture',
exitPictureInPicture: 'Exit picture-in-picture',
currentTime: 'Current time',
duration: 'Duration',
remaining: 'Remaining',
seekForward: 'Seek forward {seconds} seconds',
seekBackward: 'Seek backward {seconds} seconds',
playbackRate: 'Playback rate {rate}',
timePosition: '{current} of {duration}',
} satisfies Partial<Translations>;
// locales/es.ts — partial locale, falls back to en.ts for missing keys
import type { Translations } from '../types';
export default {
// Play controls
play: 'Reproducir',
pause: 'Pausa',
replay: 'Repetir',
// Mute
mute: 'Silenciar',
unmute: 'Activar sonido',
// …
} satisfies Partial<Translations>;
The satisfies constraint checks key names and value types without widening the type — keyof typeof es remains the narrow set of keys present, not keyof Translations. Comments above groups of keys are useful context for translators.
For CDN, locale files are self-registering ES modules. The build emits a separate CDN entry point alongside the bundler-importable output:
// locales/es.cdn.ts — CDN entry, self-registers
import { registerI18n } from '../registry';
import translations from './es';
registerI18n('es', translations);
// 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:
export function createI18n() {
const I18nContext = createContext<Translator | null>(null);
function I18nProvider({ locale: explicitLocale, translations, children }: {
locale?: Locale;
translations?: Partial<Translations>;
children: ReactNode;
}) {
// Subscribe to ambient DOM lang attribute when no explicit prop is given
const ambientLocale = useSyncExternalStore(
subscribeToDocumentLang,
() => document.documentElement.lang || undefined,
() => undefined, // SSR: no ambient locale
);
const locale = explicitLocale ?? ambientLocale;
// Lazy-load built-in overlays, then browser fallback when no pack exists.
useEffect(() => {
const seq = ++lazySeqRef.current;
const locale = resolvedLocale;
void (async () => {
const { merged, loadedTags } = await mergeLocaleOverlays(locale, loadLocale, localeLookupChain);
if (seq !== lazySeqRef.current) return;
if (shouldAttemptBrowserTranslation(locale, loadedTags, merged)) {
const browser = await getBrowserTranslations(locale);
if (Object.keys(browser).length) registerI18n(locale, browser);
}
if (seq !== lazySeqRef.current) return;
setLazyLayer(merged);
})();
}, [resolvedLocale]);
// 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>;
}
function useTranslator(): Translator {
return useContext(I18nContext) ?? createTranslator(getI18nTranslations('en'));
}
function useLocale(): Locale | undefined {
return useTranslator().locale;
}
return { I18nContext, I18nProvider, useTranslator, useLocale };
}
/** Shared MutationObserver subscription for document.documentElement.lang changes. */
function subscribeToDocumentLang(onChange: () => void): () => void {
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] });
return () => observer.disconnect();
}
HTML: createI18n factory
Parallel to React's createI18n, createI18n bundles the context, controller, provider element, and text element into one call. @videojs/html/i18n invokes it once and re-exports the result. Consumer-facing API is a single side-effect import that registers <media-i18n> / <media-text>:
// @videojs/html/i18n/create-i18n.ts
export function createI18n() {
const context = createContext<Translator>(Symbol('@videojs/i18n'));
/** Controller for any element that consumes translated strings (e.g. button labels). */
class I18nController implements ReactiveController { /* subscribes to context */ }
/** Mixin: adds lang attribute + DOM lang resolution + ContextProvider for i18nContext. */
const ProviderMixin = <Base extends Constructor<ReactiveElement>>(base: Base) =>
class extends base { /* see "Provider mixin" below */ };
/** Mixin: captures source text + subscribes to i18nContext + writes textContent. */
const TextMixin = <Base extends Constructor<ReactiveElement>>(base: Base) =>
class extends base { /* see "Text mixin" below */ };
return { context, I18nController, ProviderMixin, TextMixin };
}
Mirrors createPlayer — the factory returns building blocks (context, controller, mixins). Define files compose them onto concrete bases and register the custom elements:
// @videojs/html/src/define/media-i18n.ts
import { createI18n } from '@videojs/html/i18n';
const { ProviderMixin } = createI18n();
export class MediaI18nProviderElement extends ProviderMixin(ReactiveElement) {}
safeDefine('media-i18n', MediaI18nProviderElement);
// @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:
const ProviderMixin = <Base extends Constructor<ReactiveElement>>(base: Base) =>
class extends base {
static override properties = { ...base.properties, lang: { type: String, reflect: true } };
/** Explicit override. When unset, resolves from DOM ancestor chain. */
lang: Locale | undefined = undefined;
readonly #provider = new ContextProvider(this, {
context,
initialValue: createTranslator(getI18nTranslations('en')),
});
#unsubscribeRegistry: (() => void) | null = null;
#langObserver: MutationObserver | null = null;
/** Resolve effective locale: explicit lang attribute → nearest ancestor[lang] → documentElement.lang → undefined. */
get #effectiveLocale(): Locale | undefined {
if (this.lang) return this.lang;
const ancestor = this.parentElement?.closest('[lang]');
return (ancestor?.getAttribute('lang') || document.documentElement.lang) || undefined;
}
override connectedCallback(): void {
super.connectedCallback();
this.#unsubscribeRegistry = onI18nRegistryChange(() => this.#refresh());
// Observe document-wide lang attribute changes so ambient updates re-render descendants.
this.#langObserver = new MutationObserver(() => this.#refresh());
this.#langObserver.observe(document.documentElement, {
subtree: true, attributes: true, attributeFilter: ['lang'],
});
this.#refresh();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#unsubscribeRegistry?.();
this.#langObserver?.disconnect();
this.#unsubscribeRegistry = this.#langObserver = null;
}
protected override updated(changed: PropertyValues): void {
super.updated(changed);
if (changed.has('lang')) this.#refresh();
}
async #resetLazyAndLoad(): Promise<void> {
const localeSnapshot = resolveProviderLocale(this);
this.#lazyResetStartedForLocale = localeSnapshot;
this.#lazySeq += 1;
const seq = this.#lazySeq;
this.#lazyLayer = {};
void (async () => {
const { merged, loadedTags } = await mergeLocaleOverlays(localeSnapshot, loadLocale, localeLookupChain);
if (seq !== this.#lazySeq) return;
if (shouldAttemptBrowserTranslation(localeSnapshot, loadedTags, merged)) {
const browser = await getBrowserTranslations(localeSnapshot);
if (Object.keys(browser).length) registerI18n(localeSnapshot, browser);
}
if (seq !== this.#lazySeq) return;
this.#lazyLayer = merged;
this.requestUpdate();
})();
}
#publish(): void {
const locale = resolveProviderLocale(this);
const translations = { ...getI18nTranslations(locale), ...this.#lazyLayer };
this.#i18nProvider.setValue({ translator: createTranslator(translations, locale), locale });
}
};
HTML: Text mixin
Renders a translated string for its initial text content. Subscribes to i18nContext:
const TextMixin = <Base extends Constructor<ReactiveElement>>(base: Base) =>
class extends base {
readonly #i18n = new I18nController(this);
#text: string | undefined;
override connectedCallback(): void {
this.#text ??= this.textContent?.trim() ?? '';
super.connectedCallback();
}
protected override update(changed: PropertyValues): void {
super.update(changed);
this.textContent = this.#text ? this.#i18n.value(this.#text) : '';
}
};
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.
<!-- 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>.
<!-- Ejected skin: free-floating translated copy with no trigger button -->
<media-tooltip id="help">
<media-text>Seek</media-text>
</media-tooltip>
HTML: I18nController and MediaButtonElement
I18nController is returned from createI18n() alongside the mixins, closing over the same context symbol so it reads the value set by any ProviderMixin-backed element. Added to MediaButtonElement and MediaUIElement for auto-forwarded labels:
// Returned from createI18n() — closes over the factory's context
class I18nController implements ReactiveController {
readonly #consumer: ContextConsumer<typeof context, ReactiveElement>;
constructor(host: ReactiveElement) {
this.#consumer = new ContextConsumer(host, { context, subscribe: true });
host.addController(this);
}
get value(): Translator {
return this.#consumer.value ?? createTranslator(getI18nTranslations('en'));
}
hostConnected(): void {}
hostDisconnected(): void {}
}
// MediaButtonElement.update()
readonly #i18n = new I18nController(this);
protected override update(changed: PropertyValues): void {
super.update(changed);
const media = this.mediaState.value;
if (!media) return;
this.core.setMedia(media);
const state = this.core.getState();
const t = this.#i18n.value;
const key = this.core.getLabel(state); // opaque key, e.g. 'play'
const params = this.core.getLabelParams?.(state);
this.setAttribute('aria-label', t(key, params));
applyElementProps(this, this.core.getAttrs(state));
applyStateDataAttrs(this, state, this.stateAttrMap);
}
When no <media-i18n> ancestor is present, I18nController.value falls back to a translator seeded from the English registry — English strings are rendered by default.
Locale resolution
The provider resolves its active locale from the first source that yields a value:
1. Explicit locale prop (React) / lang attribute (HTML) on the provider
2. Nearest ancestor element with a [lang] attribute (HTML only; document.documentElement.lang in React)
3. undefined → English defaults
<media-i18n> walks the DOM with this.parentElement?.closest('[lang]') and falls back to document.documentElement.lang. Both the explicit attribute and ambient sources are tracked — a MutationObserver watches document.documentElement's subtree for lang attribute changes, so any update to <html lang> (or any intermediate wrapper's lang) re-renders descendants automatically.
React's I18nProvider uses useSyncExternalStore over document.documentElement.lang for the ambient value. SSR returns undefined from the server snapshot; on hydration the client reads the rendered <html lang> attribute, keeping server and client in sync.
This matches the inheritance semantics of the native HTML lang attribute — set it once at the page level and every player picks it up without extra wiring.
BCP 47 fallback
Locale values are BCP 47 language tags. The registry lookup in getI18nTranslations(locale) applies a left-truncation fallback — progressively dropping the rightmost subtag until a registered entry is found:
es-419-u-nu-latn → es-419 → es → en
zh-Hant-HK → zh-Hant → zh → en
pt-BR → pt-BR → pt → en
en-GB-scotland → en-GB-scotland → en-GB → en
sr-Latn → sr-Latn → sr → en
The algorithm treats each hyphen as a subtag boundary and truncates from the right. Script subtags (Hant, Latn), region subtags (419, HK, GB), and extension subtags (u-nu-latn) are all handled uniformly — no special cases.
What it does not do. Sibling fallback is not implemented: a request for es-AR will not fall back to es-MX even if only es-MX is registered. The chain is strictly up the parent hierarchy. Consumers who need sibling negotiation should register the base language (es) or the specific variants they want to cover.
<media-i18n> 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 (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 in production providers.
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.
Providers call shouldAttemptBrowserTranslation(locale, loadedTags, merged) 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).
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 (registerI18n after async translate; pre-installed model only)
↑
Registry pack (registerI18n / CDN locale modules)
↑
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-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.
Intl.PluralRules — powers the pluralize utility for skin authors who need locale-correct plurals in custom components:
import { pluralize } from '@videojs/utils/i18n';
const label = `${count} ${pluralize(count, { one: t('minute'), other: t('minutes') }, locale)}`;
SSR & hydration safety
Intl APIs produce locale-specific output. If the active locale differs between server and client, hydration mismatches occur. The fix: always derive the locale from the same source on both sides (URL routing, cookie, or Accept-Language header passed explicitly).
HTTP request
→ read locale from cookie / URL / Accept-Language
→ set <html lang={locale}> on the server-rendered document
→ load translations
→ server render: <Provider><I18nProvider translations={t}>… (locale inherited from <html lang>)
→ client hydrate: <Provider><I18nProvider translations={t}>… ← identical = no mismatch
Passing locale explicitly to I18nProvider also works but is only required when overriding the document locale for a subtree. The ambient-locale pattern makes single-locale pages zero-config.
CDN build strategy
The CDN bundle is an ES module. Locale files are separate self-registering ES modules — they import registerI18n from the same CDN URL, sharing the singleton registry instance with no global namespace touch:
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/video.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/locales/es.js"></script>
// https://cdn.jsdelivr.net/npm/@videojs/html/cdn/locales/es.js
import { registerI18n } from 'https://cdn.jsdelivr.net/npm/@videojs/html/cdn/i18n.js';
registerI18n('es', { play: 'Reproducir', pause: 'Pausa', /* … */ });
<media-i18n lang="es"> reads from the registry regardless of how data arrived — same element, same attribute, whether the translations came from an inline registerI18n call or a CDN locale module.
Decisions
Opaque camelCase keys, not English strings
Decision. Keys are camelCase identifiers (play, seekForward). The English string is the value in en.ts.
Alternatives considered.
- English as key —
t('Play')falls back to'Play'automatically. Self-documenting. Renaming'Play'to'Start'in English breaks all other locale files. Established by VJS v7/8 and Media Chrome. - Numeric keys — stable, compact, completely opaque. No useful fallback.
- Namespaced paths —
t('controls.play'). Common in i18next; adds indirection.
Rationale. Stable keys decouple English UX copy from locale file versioning. Adding a new language or updating an English label are independent operations. The English string is still the default — it lives in en.ts, which is always pre-registered as the base layer. Consistent with i18next, FormatJS, iOS NSLocalizedString, and Android strings.xml.
Global registry, not lazy-loading on skin element
Decision. A singleton Map in @videojs/core/i18n. registerI18n(locale, translations) is the imperative entry point. <media-i18n> reads from it.
Alternatives considered.
- JSON attribute on skin element —
<video-skin translations='…'>. Couples translations to the skin element; JSON attributes are fragile and lose type safety. - Side-effect locale imports —
import '@videojs/core/i18n/locales/es'auto-registers. Media Chrome uses this pattern (import 'media-chrome/lang/es.js'). Simple for CDN, but bundlers may include unwanted locales, and there's no explicit registration call for overrides. - Lazy-load only inside provider — each provider independently loads its pack. Multiple players for the same locale each trigger a separate network request.
Rationale. Module-level singleton means each locale is loaded once per page regardless of how many players are mounted. For CDN, locale files are self-registering ES modules — they import registerI18n from the same module URL, sharing the registry instance with no global namespace touch. For bundlers, consumers 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
Decision. Neither HTML skins nor React skins own the i18n provider. Consumers add <media-i18n> or <I18nProvider> explicitly and place them wherever they need.
Alternatives considered.
I18nMixinon the skin element — skin element ownstranslationsandlangattributes. Simpler consumer API but couples skins to i18n. Addinglangto<video-skin>creates an attribute collision with the native HTMLlangattribute.locale/translationsprops on<VideoSkin>— skin internally wraps itself with provider. Zero friction, but skin must know about i18n; harder to share a provider across multiple skins.
Rationale. Skins are presentation layers. i18n is a cross-cutting concern. Decoupling follows the same principle as <Provider> for the store — skins consume context, providers supply it, and neither owns the other. Consumers can wrap multiple skins in one provider, or give each its own. The HTML lang attribute conflict disappears since <media-i18n lang="es"> is a distinct element.
Provider inherits locale from the DOM
Decision. When no explicit locale prop (React) / lang attribute (HTML) is set on the provider, it resolves the locale from the nearest ancestor [lang] attribute (HTML) or document.documentElement.lang (React). A MutationObserver watches for changes so updates to <html lang> propagate automatically.
Alternatives considered.
- Explicit locale always required on the provider — previous revision behavior. Ergonomic friction: every app must pass the locale into the provider even when
<html lang>already declares the page language. - Skin reads DOM
langdirectly, no provider — would eliminate the provider for simple cases, but the provider is needed anyway for context distribution, registry subscription, and inlinetranslations. - Read
navigator.language— reflects the user's browser preference, not the page's declared language. Can be wrong (e.g., a Spanish-language page viewed by a French user).
Rationale. The HTML lang attribute is the standard mechanism for declaring content language — search engines, screen readers, and spell-checkers already read it. Making the i18n provider honor the same attribute means zero extra config for the common case: set <html lang="es"> once and every player on the page uses Spanish. The provider remains explicit and required; only its locale input becomes optional. React uses locale as the prop name (aligning with Intl and the rest of the TypeScript surface) while HTML uses the native lang attribute.
createI18n() factory, not direct exports
Decision. A factory that closes over a React context. Returns { I18nContext, I18nProvider, useTranslator, useLocale }. Called once in @videojs/react/i18n and re-exported.
Alternatives considered.
- Direct module-level exports —
I18nProvideranduseTranslatorexported 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.
Built-in tooltips: trigger sync, not <media-text>
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>Play</media-text>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>…</media-text> element renders translated text inside shadow DOM templates where no component owns the phrase.
Alternatives considered.
- 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 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
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 remainingTimeSuffix and volumeSliderValueTextMuted need translation keys because Intl has no concept of those suffixes.
Browser Translation API — pre-installed model only
Decision. Only activate when Translator.availability() returns 'available'. Skip silently for all other states.
Rationale. 'downloadable' triggers a ~100 MB background download without consumer awareness. 'available' means the model is already on-device — zero cost, free bonus. The feature is not reliable enough to be the primary translation mechanism; built-in locale packs are.
Prior Art
| System | Key type | Distribution | Registry? |
|---|---|---|---|
| VJS v7/8 | English string | Built-in JSON + consumer | No — set via videojs.addLanguage |
| i18next | Opaque string | Consumer JSON | Yes — i18next.addResourceBundle |
| FormatJS / react-intl | Opaque string | Consumer JSON | No — prop-based |
| GNU gettext | English string | .po files |
No |
Android strings.xml |
Opaque XML ID | Resource files | No |
iOS NSLocalizedString |
Opaque string | .strings files |
No |
| Media Chrome | English string | Side-effect import "media-chrome/lang/es.js" |
Yes — auto-registered on import; lang attribute on <media-controller> |
VJS v8's addLanguage is the closest precedent. This design replaces English-as-key (VJS v7/8) with opaque keys (consistent with i18next/Android/iOS) while keeping the imperative registration API.
Edge Cases
Multiple players, different languages. Each <media-i18n> or <I18nProvider> scopes its locale independently. The registry holds all registered locales simultaneously — providers read from it without interfering with each other. An explicit locale prop (React) or lang attribute (HTML) on the provider overrides any ambient <html lang>.
registerI18n called after element mounts. <media-i18n> subscribes to onI18nRegistryChange. Calling registerI18n after mount triggers a re-render of all descendant elements.
<html lang> changed at runtime. Both HTML and React providers observe document.documentElement via MutationObserver. Dynamic locale switching works without remounting — update document.documentElement.lang and all providers without an explicit override re-render.
SSR flash-of-English. React I18nProvider starts with builtIn: {} — first render is English unless translations is passed. Import the locale module directly and pass as translations for zero flash (see SSR section above).
en.ts bundle footprint. en.ts is imported as a module-level side effect of @videojs/core/i18n. Consumers who never use i18n should not import this module — they will not pay the cost if they use only @videojs/core/ui or similar.
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
- 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 thepluralizeutility 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
textTrackagainst 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 —
createTranslatorre-parses{param}templates on every call viaString.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 tobabel-plugin-formatjs/lingui`).
- Custom translator implementations — a consumer-supplied
translate(key) → stringoverride (e.g. to delegate toi18nextor FormatJS). Deferred follow-up; useregisterI18nor Reacttranslationsprop today.
File Structure
packages/
├── core/src/core/i18n/
│ ├── types.ts ← 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 (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
│ ├── 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 }
├── locales/ ← generated re-exports of core/locales/*.ts
├── define/
│ ├── media-i18n.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
Adding a locale: author packages/core/src/core/i18n/locales/{tag}.ts, append the tag to 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 |
|---|---|
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/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() |