diff --git a/internal/design/i18n/architecture.md b/internal/design/i18n/architecture.md new file mode 100644 index 000000000..46607e54d --- /dev/null +++ b/internal/design/i18n/architecture.md @@ -0,0 +1,917 @@ +--- +status: draft +date: 2026-03-25 +--- + +# Architecture + +## Layers + +``` +@videojs/core/i18n + Translations (index sig) · Translator · createTranslator + │ + ├── @videojs/react/i18n + │ createI18nHooks() → { I18nContext, I18nProvider, useTranslator, useLocale } + │ Each skin calls createI18nHooks() once + │ Skin root wraps player with + │ Components call useTranslator() in render + │ + └── @videojs/html/i18n + I18nMixin → i18nContext ContextProvider on skin element + I18nController → ContextConsumer on each custom element + MediaButtonElement.update() applies t() to aria attrs + +@videojs/utils/i18n + pluralize(count, forms, locale?) ← wraps Intl.PluralRules + +@videojs/utils/time + formatDuration(seconds, options?) ← Intl.DurationFormat with translate fallback +``` + +## Core Layer — `@videojs/core/i18n/` + +``` +packages/core/src/i18n/ +├── types.ts ← Translations, Translator, Locale, BuiltInLocale +├── translator.ts ← createTranslator +└── index.ts ← re-exports +``` + +### `Translations`, `Translator`, and `Locale` + +```ts +// types.ts +/** + * The locale tags for which Video.js ships built-in translation packs. + * Surfaces as autocomplete on the `locale` prop while still accepting any BCP 47 tag. + */ +export type BuiltInLocale = + | 'ar' | 'de' | 'es' | 'fr' | 'it' | 'ja' + | 'ko' | 'nl' | 'pl' | 'pt' | 'ru' | 'tr' | 'zh'; + +/** + * Any BCP 47 locale tag. Known built-in locales autocomplete in editors; + * any other tag (e.g. 'pt-BR', 'zh-TW', 'en-AU') is accepted without a cast. + * + * `string & {}` prevents TypeScript from collapsing the union to plain `string`, + * which is what preserves the autocomplete for `BuiltInLocale` members. + */ +export type Locale = BuiltInLocale | (string & {}); + +export type Translator = { + (text: keyof T & string, params?: Record): string; + /** The BCP 47 locale passed to the nearest I18nProvider. Used by Intl APIs. */ + readonly locale?: Locale; +}; +``` + +`BuiltInLocale` is derived from the set of JSON files in `@videojs/core/i18n/locales/` — adding a new file is the only step needed to expand the known set. + +The `locale` property on the translator is how components access the current locale without a second hook. `formatDuration` reads it as `t.locale`. + +### `createTranslator` + +```ts +// translator.ts +export function createTranslator( + translations: Partial = {} as Partial, + locale?: Locale +): Translator { + function t(text: keyof T & string, params?: Record): string { + let value = (translations[text] ?? text) 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; +} +``` + +Key behaviors: + +- `translations[text] ?? text` — falls back to the key (English) when no translation is provided. +- `{param}` replacement is a simple string scan — no regex, no runtime library. +- The generic `T` controls which keys `text` accepts on the returned `Translator`. Passing `Partial` (rather than `T`) as the first argument avoids forcing callers to supply every key when merging built-in and consumer packs. +- Returns the key verbatim when called with no arguments (`createTranslator()` is the identity translator, typed to `Translator`). + +### `Translations` + +```ts +// types.ts +/** + * Structural base for all skin translation types. + * The index signature is what enables English-as-fallback for any missing key. + * Named player keys live in each skin's own extension of this interface. + */ +export interface Translations { + [key: string]: string | undefined; +} +``` + +`Translations` carries only the index signature — no named player keys. Skins define their own interface that extends `Translations` and declares exactly the keys their UI uses: + +```ts +// packages/react/src/presets/video/i18n.ts +import type { Translations } from '@videojs/core/i18n'; + +export interface VideoSkinTranslations extends Translations { + // Play controls + Play?: string; + Pause?: string; + Replay?: string; + // Mute + Mute?: string; + Unmute?: string; + // Seek button (interpolated) + 'Seek forward {seconds} seconds'?: string; + 'Seek backward {seconds} seconds'?: string; + // Slider labels (aria-label) + Seek?: string; + Volume?: string; + // Volume aria-valuetext suffix when muted (percent is formatted by Intl.NumberFormat) + muted?: string; + // Fullscreen + 'Enter fullscreen'?: string; + 'Exit fullscreen'?: string; + // Captions + 'Enable captions'?: string; + 'Disable captions'?: string; + // Picture-in-picture + 'Enter picture-in-picture'?: string; + 'Exit picture-in-picture'?: string; + // Playback rate (interpolated) + 'Playback rate {rate}'?: string; + // Time display labels (aria-label) + 'Current time'?: string; + Duration?: string; + Remaining?: string; + // Time slider aria-valuetext (interpolated — params are already-formatted phrases) + '{current} of {duration}'?: string; + // Negative time suffix — Intl.DurationFormat has no concept of remaining time + remaining?: string; + // Skin-specific keys can be added here + // 'Skip Intro'?: string; +} + +export const VIDEO_SKIN_TRANSLATION_KEYS: ReadonlyArray = [ + 'Play', 'Pause', 'Replay', + 'Mute', 'Unmute', + 'Seek', 'Volume', 'muted', + 'Current time', 'Duration', 'Remaining', + 'Seek forward {seconds} seconds', + 'Seek backward {seconds} seconds', + 'Playback rate {rate}', + '{current} of {duration}', + 'remaining', +] as const; +``` + +All keys are optional — every key falls back to the English string (the key itself). Keeping the named keys in the skin package means a new skin can define a completely different key set without touching core. + +> **No time unit keys.** `Intl.DurationFormat` handles unit labels, pluralization, and locale-specific ordering in all target environments. Only `remaining` is present because `Intl.DurationFormat` has no concept of remaining time. + +### Core classes are not changed + +`PlayButtonCore.getLabel()`, `MuteButtonCore.getLabel()`, etc. continue to return English strings. Translation is applied at the UI layer (React components and HTML custom elements), not inside Core classes. Core stays runtime-agnostic. + +**Exception — parametric cores.** `SeekButtonCore` and `PlaybackRateButtonCore` currently return the _resolved_ string from `getLabel()` (e.g., `'Seek forward 10 seconds'`). For the UI layer to call `t('Seek forward {seconds} seconds', { seconds: 10 })`, the template key and params must be available separately. These two cores are refactored: `getLabel(state)` returns the template key string, and a new `getLabelParams(state)` returns the interpolation params. The resolved English string can be produced by calling `t(core.getLabel(state), core.getLabelParams(state))` with the identity translator. + +## React Layer — `@videojs/react/i18n` + +``` +packages/react/src/i18n/ +├── create-i18n-hooks.tsx ← createI18nHooks +├── browser-translation.ts ← getBrowserTranslations, module-level cache +└── index.ts +``` + +### `createI18nHooks` + +A factory called once per skin. It closes over a typed React context so that `useTranslator()` returns `Translator` — giving full TypeScript autocomplete for the skin's own key set. + +```tsx +// create-i18n-hooks.tsx +export function createI18nHooks() { + const I18nContext = createContext | null>(null); + + function I18nProvider({ locale, translations, translationKeys, children }: { + locale?: Locale; + translations?: Partial; + /** Keys to pass to the browser Translation API. Typically the skin's own key constant. */ + translationKeys?: ReadonlyArray; + children: ReactNode; + }) { + // Built-in locale pack — lazy-loaded, curated translations + const [builtIn, setBuiltIn] = useState>({}); + useEffect(() => { + if (!locale) { setBuiltIn({} as Partial); return; } + // Try exact tag, then base language (e.g. "pt-BR" → "pt") + const tags = [...new Set([locale, locale.split('-')[0]])]; + const load = (i = 0): Promise => + import(`@videojs/core/i18n/locales/${tags[i]}.json`) + .then(m => setBuiltIn(m.default)) + .catch(() => (i + 1 < tags.length ? load(i + 1) : setBuiltIn({} as Partial))); + load(); + }, [locale]); + + // Browser Translation API — background fallback, only when model already present + const [browserTranslated, setBrowserTranslated] = useState>({}); + useEffect(() => { + if (!locale || !translationKeys?.length) { setBrowserTranslated({} as Partial); return; } + let cancelled = false; + getBrowserTranslations(locale, translationKeys).then(result => { + if (!cancelled) setBrowserTranslated(result as Partial); + }); + return () => { cancelled = true; }; + }, [locale, translationKeys]); + + // Priority: browser API < built-in pack < consumer translations + const translator = useMemo( + () => createTranslator({ ...browserTranslated, ...builtIn, ...translations }, locale), + [browserTranslated, builtIn, translations, locale] + ); + + const player = usePlayer({ optional: true }); + useEffect(() => { + player?.selectCaptionsByLocale(locale); + }, [player, locale]); + + return {children}; + } + + function useTranslator(): Translator { + // Falls back to identity translator — components always render English with no provider + return useContext(I18nContext) ?? (createTranslator() as Translator); + } + + function useLocale(): Locale | undefined { + return useTranslator().locale; + } + + return { I18nContext, I18nProvider, useTranslator, useLocale }; +} +``` + +### How components use `useTranslator` + +Each skin exports its own `useTranslator` from its `createI18nHooks()` call. Components in that skin import and call it — TypeScript knows which keys are valid for that skin: + +```tsx +// Inside a VideoSkin component +import { useTranslator } from '../i18n'; + +const t = useTranslator(); // Translator + +// Simple label +const ariaLabel = t(core.getLabel(state)); // key is keyof VideoSkinTranslations & string + +// Parametric label (SeekButton, PlaybackRateButton) +const ariaLabel = t(core.getLabel(state), core.getLabelParams(state)); + +// Skin-specific key +const label = t('Skip Intro'); // typed — only valid because VideoSkinTranslations includes it +``` + +For `VolumeSliderCore` aria-valuetext: + +```tsx +const t = useTranslator(); +const pct = new Intl.NumberFormat(t.locale, { style: 'percent' }).format(state.value / 100); +const valuetext = state.muted ? `${pct}, ${t('muted')}` : pct; +``` + +For `TimeSliderCore` aria-valuetext — the time phrases are formatted first, then composed: + +```tsx +const t = useTranslator(); +const options = { translate: t, locale: t.locale }; +const currentPhrase = formatDuration(state.value, options); +const durationPhrase = formatDuration(state.duration, options); +const valuetext = t('{current} of {duration}', { + current: currentPhrase, + duration: durationPhrase, +}); +``` + +### Skin integration + +Each skin defines its translation interface and calls `createI18nHooks` once, then wraps its subtree with the resulting `I18nProvider`. `I18nProvider` sits inside `Provider` so it can call `usePlayer()` for caption selection: + +```tsx +// packages/react/src/presets/video/i18n.ts +export interface VideoSkinTranslations extends Translations { /* ... */ } +export const VIDEO_SKIN_TRANSLATION_KEYS = [...] as const; +export const { I18nContext, I18nProvider, useTranslator, useLocale } = + createI18nHooks(); + +// packages/react/src/presets/video/skin.tsx +function VideoSkin({ locale, translations, ...props }: VideoSkinProps) { + return ( + + + {/* UI */} + + + ); +} +``` + +## HTML Layer — `@videojs/html/i18n` + +``` +packages/html/src/i18n/ +├── context.ts ← i18nContext (ContextProvider/Consumer keys) +├── controller.ts ← I18nController (ReactiveController + ContextConsumer) +└── mixin.ts ← I18nMixin (ContextProvider on skin element) +``` + +### Why a separate `i18nContext` + +The HTML player already propagates state via `playerContext`, `mediaContext`, and `containerContext`. Adding a translator to `playerContext` would require threading it through `createPlayer`, `PlayerStore`, and every `PlayerController` — a large blast radius with no related benefit. A separate `i18nContext` is independent of the store type and follows the pattern of `alertDialogContext`, `sliderContext`, and `tooltipContext`. + +### `i18nContext` + +```ts +// context.ts +import { createContext } from '@videojs/element/context'; +import type { Translations, Translator } from '@videojs/core/i18n'; + +// Typed to the base Translator — HTML elements call t() with string literals +// so the open index signature is sufficient; no skin-specific generic needed here. +export const i18nContext = createContext>( + Symbol.for('@videojs/i18n') +); +``` + +### `I18nMixin` + +Applied to skin elements (`VideoSkinElement`, `AudioSkinElement`). Provides the translator to all descendant custom elements via `i18nContext`. + +```ts +// mixin.ts +export function I18nMixin>( + BaseClass: Base +): Base { + class I18nElement extends BaseClass { + static override properties = { + ...BaseClass.properties, + locale: { type: String }, + translations: { type: Object }, + }; + + locale: Locale | undefined = undefined; + translations: Translations = {}; + /** Keys to translate via the browser Translation API. Set by the skin element. */ + translationKeys: ReadonlyArray = []; + + #builtIn: Partial = {}; + #browserTranslated: Partial = {}; + + #provider = new ContextProvider(this, { + context: i18nContext, + initialValue: createTranslator({}), + }); + + async #loadLocale(locale: Locale): Promise { + const tags = [...new Set([locale, locale.split('-')[0]])]; + for (const tag of tags) { + try { + const { default: data } = await import( + `@videojs/core/i18n/locales/${tag}.json` + ); + this.#builtIn = data; + this.#updateProvider(); + return; // Built-in pack found — skip browser API + } catch { /* try next tag */ } + } + // No built-in pack — render English immediately, then try the browser API + this.#builtIn = {}; + this.#updateProvider(); + if (this.translationKeys.length) { + getBrowserTranslations(locale, this.translationKeys).then(result => { + this.#browserTranslated = result; + this.#updateProvider(); + }); + } + } + + #updateProvider(): void { + // Priority: browser API < built-in pack < consumer translations + this.#provider.setValue( + createTranslator( + { ...this.#browserTranslated, ...this.#builtIn, ...this.translations }, + this.locale + ) + ); + } + + protected override updated(changed: PropertyValues): void { + super.updated(changed); + if (changed.has('locale')) { + if (this.locale) this.#loadLocale(this.locale); + else this.#updateProvider(); + // I18nMixin sits on the skin element which also has SkinMixin (store access) + this.store?.selectCaptionsByLocale(this.locale); + } + if (changed.has('translations')) { + this.#updateProvider(); + } + } + } + + return I18nElement as unknown as Base; +} +``` + +Usage in a skin element. The skin sets `translationKeys` to its own key constant so the mixin knows which strings to pass to the browser Translation API: + +```ts +import { VIDEO_SKIN_TRANSLATION_KEYS } from './i18n'; + +export class VideoSkinElement extends I18nMixin(SkinMixin(ReactiveElement)) { + static readonly tagName = 'video-skin'; + + override translationKeys = VIDEO_SKIN_TRANSLATION_KEYS; +} +``` + +### `I18nController` + +A `ReactiveController` that consumes `i18nContext`. Added to `MediaButtonElement` and `MediaUIElement` so all custom elements get translation with a single change. + +```ts +// controller.ts +export class I18nController implements ReactiveController { + readonly #consumer: ContextConsumer; + + constructor(host: ReactiveElement) { + this.#consumer = new ContextConsumer(host, { + context: i18nContext, + subscribe: true, + }); + host.addController(this); + } + + get value(): Translator { + // Falls back to identity translator if no I18nMixin ancestor is present + return this.#consumer.value ?? createTranslator(); + } + + hostConnected(): void {} + hostDisconnected(): void {} +} +``` + +### `MediaButtonElement.update()` + +```ts +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; + + // Apply translated aria-label + const key = this.core.getLabel(state); + const params = this.core.getLabelParams?.(state); + this.setAttribute('aria-label', t(key, params)); + + // Apply other attrs and data attrs + applyElementProps(this, this.core.getAttrs(state)); + applyStateDataAttrs(this, state, this.stateAttrMap); +} +``` + +### Tooltip text in template HTML + +Static English strings appear in `getTemplateHTML()` for skin elements (e.g., the text content of `` elements). Each `` element subscribes to `i18nContext` directly via `I18nController` and updates its text content in its own `update()` lifecycle — the same context-propagation pattern used for `aria-label` on buttons. + +This means `` is not a dumb rendering element. It receives a translation key via an attribute (e.g., `label="Play"`) and renders the translated string as its text content: + +```html + +``` + +```ts +// MediaTooltipElement.update() +const t = this.#i18n.value; +this.textContent = t(this.label); +``` + +## SSR & Locale + +### The hydration problem + +`Intl.DurationFormat`, `Intl.NumberFormat`, and `Intl.PluralRules` each produce locale-specific output. If no locale is passed explicitly, the runtime picks one — and the runtime differs between server (Node.js) and browser (`navigator.language`). Even a subtle difference (`en-US` vs `en-GB`) produces different output, causing a React hydration mismatch. + +```tsx +// ❌ Unsafe — navigator.language is undefined on the server +const locale = navigator.language; + +// ❌ Also unsafe — server renders 'en', browser may read 'de' from navigator +const locale = typeof window === 'undefined' ? 'en' : navigator.language; +``` + +The fix is always to pass `locale` explicitly and derive it from the same source on both server and client. + +### Getting locale on the server + +**URL routing (recommended for Next.js)** + +The locale is in the URL (`/es/watch`), making it available on both server and client from the same source. next-intl handles this out of the box: + +```tsx +// app/[locale]/layout.tsx +import { getLocale } from 'next-intl/server'; + +const locale = await getLocale(); // e.g. 'es' +``` + +**Cookie (framework-agnostic)** + +Store the user's preferred locale in a cookie after their first visit or explicit selection. Cookies are available on both server and client — the same value is readable in SSR and hydration: + +```ts +// Server (any framework) +const locale = req.cookies['locale'] ?? parseAcceptLanguage(req.headers['accept-language']) ?? 'en'; + +// Client (same cookie, no mismatch) +const locale = document.cookie.match(/locale=([^;]+)/)?.[1] ?? 'en'; +``` + +**`Accept-Language` header (server-only first render)** + +`Accept-Language` is available in HTTP request headers. It's server-only — not available on the client. This is safe for the _initial_ server render as long as the detected locale is passed to `I18nProvider` and React uses it consistently during hydration: + +```tsx +// Next.js server component +import { headers } from 'next/headers'; + +const acceptLanguage = (await headers()).get('accept-language') ?? 'en'; +const locale = parseLocale(acceptLanguage); // e.g. 'de' from 'de-DE,de;q=0.9,en;q=0.8' + + + {children} + +``` + +The risk: if the user then navigates client-side and the locale is re-derived from `navigator.language` rather than the same source, the value may diverge. Persist the locale to a cookie on first request to avoid this. + +### Hydration-safe pattern + +``` +HTTP request + → read locale from cookie (or URL segment, or Accept-Language) + → load translations for that locale + → server render: + → client hydrate: + ↑ identical value = no mismatch +``` + +### Client-only players (no SSR) + +If the player is rendered entirely client-side (e.g., a CSR SPA), `navigator.language` is safe to use: + +```ts +const locale = navigator.language; // 'de-DE', 'en-US', etc. +``` + +## Locale-based caption selection + +### How it works + +The store's `textTrackList` already captures `language` (mapped from the DOM `TextTrack.language` / `srclang` attribute) for every track. When `locale` is set, a new store feature compares it against the track list and activates the best match. + +Caption selection is driven by `I18nProvider` directly — not by the skin. `I18nProvider` owns the locale and is the right place for locale-driven side effects. Skins should have no knowledge of this coupling. + +This requires `I18nProvider` to sit _inside_ `Provider` so it can call `usePlayer()`. The nesting order is: + +```tsx +// Store (infrastructure) → i18n (behaviour) → skin (UI) + + + + + +``` + +`I18nProvider` calls `selectCaptionsByLocale` whenever `locale` changes (this is already included in the `createI18nHooks` implementation above). `selectCaptionsByLocale` is a store action added to the text-track feature. Passing `undefined` disables auto-selection. + +### Matching algorithm + +BCP 47 base-language matching — the locale region subtag is stripped for fallback: + +```ts +function matchLocale(trackLanguage: string, locale: string): number { + if (trackLanguage === locale) return 2; // exact match + if (trackLanguage.split('-')[0] === locale.split('-')[0]) return 1; // base match + return 0; // no match +} +``` + +When multiple tracks score the same, the first in `textTrackList` order wins. Tracks with `kind: 'captions'` and `kind: 'subtitles'` are both considered. + +### User override + +The text-track feature tracks a `localeSelectionOverridden` flag. It is set to `true` when the user manually selects or deselects a track (a DOM `textTracks change` event where the mode change did not originate from `selectCaptionsByLocale`). While this flag is `true`, locale changes do not re-trigger auto-selection. + +The flag resets when the media source changes — a new video is a clean slate. + +### Integration with SPF + +For SPF playback, track selection goes through `selectedTextTrackId`. `selectCaptionsByLocale` finds the matching track's `id` and sets `selectedTextTrackId` directly, which `syncTextTrackModes` then propagates to the DOM track element modes. + +For non-SPF playback, the feature falls back to `toggleSubtitles(true)` when a match is found, or `toggleSubtitles(false)` when the locale changes to one with no matching track and the previous selection was locale-driven. + +### Files + +| File | Role | +| ---- | ---- | +| `packages/core/src/core/media/state.ts` | `MediaTextTrack` — includes `language` property | +| `packages/core/src/dom/store/features/text-track.ts` | Add `selectCaptionsByLocale`, `localeSelectionOverridden` | +| `packages/spf/src/dom/features/sync-text-track-modes.ts` | `selectedTextTrackId` — used by SPF path | + +## Intl API Integration + +Three native `Intl` APIs are used to avoid reimplementing what the platform already provides. + +### `Intl.DurationFormat` — time phrases + +`Intl.DurationFormat` (baseline across Chrome 122+, Firefox 127+, Safari 18+) drives `formatDuration`. It handles unit labels, pluralization, and locale-specific ordering automatically: + +```ts +const formatter = new Intl.DurationFormat(locale, { style: 'long' }); +formatter.format({ hours: 2, minutes: 30, seconds: 0 }); +// en: "2 hours and 30 minutes" +// de: "2 Stunden und 30 Minuten" +// ar: "ساعتان و٣٠ دقيقة" +``` + +The `remaining` suffix for negative time values still uses `translate?.('remaining') ?? 'remaining'` since `Intl.DurationFormat` has no concept of remaining time. + +### `Intl.NumberFormat` — numeric values + +Volume is formatted with `Intl.NumberFormat` using `style: 'percent'`, which produces the locale-correct symbol and digit forms (`75%`, `٧٥٪`, `75 %`, etc.) with no translation key: + +```ts +const pct = new Intl.NumberFormat(locale, { style: 'percent' }).format(state.value / 100); +// e.g. en-US → "75%", ar → "٧٥٪", fr → "75 %" +``` + +The `'muted'` translation key is still needed as a suffix when the slider is muted — `Intl` has no concept of that state. + +### `Intl.PluralRules` — plural selection + +`Intl.PluralRules` powers the `pluralize` utility (see below). It is also used internally by `formatDuration` in the fallback path to select the correct singular/plural unit label without needing two separate translation keys per unit. + +## Browser Translation API + +The [Translator API](https://developer.chrome.com/docs/ai/translator-api) (WICG draft, Chrome 138+ origin trial) allows web pages to translate text using the browser's on-device model. Video.js uses it as a background fallback for locales that have no built-in locale pack. + +### Feature detection + +```ts +if ('Translator' in self) { + // API supported +} +``` + +### Availability check + +Before creating a translator, check whether the model for the requested language pair is ready: + +```ts +const availability = await Translator.availability({ + sourceLanguage: 'en', + targetLanguage: locale, +}); +// 'available' | 'downloadable' | 'downloading' | 'unavailable' +``` + +Video.js only proceeds when `availability === 'available'` — the model is already on-device and there is no network cost. The other states are silently skipped; the player falls back to English rather than triggering a ~100 MB model download. + +### `getBrowserTranslations` — shared utility with module-level cache + +Both the React and HTML layers share the same logic. A module-level `Map` ensures each locale is only translated once per page load, even if multiple players are mounted: + +```ts +// browser-translation.ts (shared between React and HTML layers) +const cache = new Map>>(); + +/** + * Fetch browser-translated strings for `locale`, using `keys` to know what to translate. + * Results are cached by locale — the first call for a locale wins. Repeated mounts of the + * same skin do not re-trigger translation. + */ +export function getBrowserTranslations( + locale: string, + keys: ReadonlyArray +): Promise> { + if (!cache.has(locale)) { + cache.set(locale, fetchBrowserTranslations(locale, keys)); + } + return cache.get(locale)!; +} + +async function fetchBrowserTranslations( + locale: string, + keys: ReadonlyArray +): Promise> { + if (!('Translator' in self)) return {}; + + const availability = await Translator.availability({ sourceLanguage: 'en', targetLanguage: locale }); + if (availability !== 'available') return {}; + + const translator = await Translator.create({ sourceLanguage: 'en', targetLanguage: locale }); + try { + const values = await Promise.all(keys.map(key => translator.translate(key))); + return Object.fromEntries(keys.map((k, i) => [k, values[i]])); + } finally { + translator.destroy(); + } +} +``` + +`signal` is intentionally not forwarded to the cached `Promise` — one component's unmount should not abort a translation that another instance is awaiting. The cache is keyed by `locale` only; the first skin mounted for a given locale determines which keys are translated. In practice all instances of the same skin have identical key sets, so this is not a problem. + +### Priority in the merge + +Browser-translated strings are the lowest-priority layer. Built-in locale packs (curated, reviewed) beat them; consumer `translations` beat everything: + +``` +English keys (key-as-fallback) + ↑ +Browser API (auto-translated, background) + ↑ +Built-in pack (curated JSON, lazy-loaded) + ↑ +Consumer props (always wins) +``` + +### Status note + +The Translator API is an early-preview origin trial as of 2026. The feature detection and `availability` check mean Video.js degrades gracefully in all non-Chrome environments and in Chrome builds where the feature is absent or the language pair is unsupported. + +--- + +## `@videojs/utils` — `pluralize` + +``` +packages/utils/src/i18n/ +└── pluralize.ts ← pluralize(count, forms, locale?) +``` + +```ts +// packages/utils/src/i18n/pluralize.ts +export type PluralForms = Partial> & { + other: string; +}; + +export function pluralize( + count: number, + forms: PluralForms, + locale?: string +): string { + const rule = new Intl.PluralRules(locale).select(count); + return forms[rule] ?? forms.other; +} +``` + +`Intl.LDMLPluralRule` covers the six CLDR plural categories: `zero | one | two | few | many | other`. Callers only supply the forms their target language uses; `other` is required as the fallback. Languages like English only need `one` and `other`; Arabic needs all six. + +Exported for skin authors who need locale-correct plurals in custom components: + +```ts +import { pluralize } from '@videojs/utils/i18n'; + +const label = `${count} ${pluralize(count, { + one: t('minute'), + other: t('minutes'), +}, locale)}`; +``` + +## `@videojs/utils` — `formatDuration` + +`formatDuration` lives in `@videojs/utils` and cannot import from `@videojs/core`. It accepts an options object with `locale` (for `Intl.DurationFormat`) and `translate` (for the `'remaining'` suffix only). `TimeTranslate` is satisfied by any `Translator` (since `keyof T = string` when `T extends Translations`) without importing that type from `@videojs/core`: + +```ts +// packages/utils/src/time/format.ts +export type TimeTranslate = (key: string) => string; + +export interface TimeFormatOptions { + locale?: string; // any BCP 47 tag — @videojs/utils cannot import Locale from @videojs/core + translate?: TimeTranslate; +} + +export function formatDuration( + seconds: number, + options?: TimeFormatOptions +): string { + // Intl.DurationFormat handles all unit labels and pluralization + const formatter = new Intl.DurationFormat(options?.locale, { style: 'long' }); + const phrase = formatter.format({ hours: h, minutes: m, seconds: s }); + + // Only translate the 'remaining' suffix for negative time values + const suffix = negative + ? ` ${options?.translate?.('remaining') ?? 'remaining'}` + : ''; + + return `${phrase}${suffix}`; +} +``` + +`TimeTranslate` is intentionally generic — it is satisfied by any `Translator` (since `keyof T` is `string` when `T extends Translations`) with no import from `@videojs/core` required. + +### Integration in `TimeSliderCore.getAttrs` + +```ts +override getAttrs(state: TimeSliderState, options?: TimeFormatOptions) { + const currentPhrase = formatDuration(state.value, options); + const durationPhrase = formatDuration(state.duration, options); + // Caller composes the full valuetext using t('{current} of {duration}', ...) + return { + ...super.getAttrs(state), + 'aria-valuetext-current': currentPhrase, + 'aria-valuetext-duration': durationPhrase, + }; +} +``` + +The HTML `TimeSliderElement` and React time slider component both pass their translator to `getAttrs`, then compose the final `aria-valuetext` using `t('{current} of {duration}', { current, duration })`. + +## File Structure Summary + +``` +packages/ +├── core/src/i18n/ +│ ├── types.ts ← BuiltInLocale, Locale, Translations (index sig only), Translator +│ ├── translator.ts ← createTranslator +│ ├── index.ts ← re-exports +│ └── locales/ ← built-in locale packs (lazy-loaded) +│ ├── ar.json +│ ├── de.json +│ ├── es.json +│ ├── fr.json +│ ├── it.json +│ ├── ja.json +│ ├── ko.json +│ ├── nl.json +│ ├── pl.json +│ ├── pt.json +│ ├── ru.json +│ ├── tr.json +│ └── zh.json +│ +├── react/src/i18n/ +│ ├── create-i18n-hooks.tsx ← createI18nHooks +│ ├── browser-translation.ts ← getBrowserTranslations(locale, keys), module-level cache +│ └── index.ts +│ +├── react/src/presets/video/ +│ └── i18n.ts ← VideoSkinTranslations, VIDEO_SKIN_TRANSLATION_KEYS, skin hooks +│ +└── html/src/i18n/ + ├── context.ts ← i18nContext (typed to Translator) + ├── controller.ts ← I18nController + ├── mixin.ts ← I18nMixin (translationKeys property) + └── browser-translation.ts ← getBrowserTranslations(locale, keys), module-level cache + +packages/utils/src/i18n/ +└── pluralize.ts ← pluralize(count, forms, locale?) + +packages/utils/src/time/ +└── format.ts ← rename formatTimeAsPhrase → formatDuration, add TimeFormatOptions +``` + +> **Which languages to ship is TBD.** The set above covers the most common global video audiences. The lazy-load architecture means adding new files has zero bundle cost. Built-in locale JSON files contain keys common to the Video skin — skins with different key sets supply their own locale packs via `translations` or a separate lazy-load mechanism. + +### Modified files + +| File | Change | +| ---- | ------ | +| `packages/core/src/core/ui/seek-button/seek-button-core.ts` | `getLabel` returns template key; add `getLabelParams` | +| `packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts` | Same | +| `packages/utils/src/time/format.ts` | Add `TimeTranslate` type + optional `translate` param | +| `packages/html/src/ui/media-button-element.ts` | Add `I18nController`, apply `t()` in `update()` | +| `packages/html/src/ui/media-tooltip-element.ts` | Add `I18nController`, render translated `label` attr | +| `packages/html/src/define/video/skin.ts` | Apply `I18nMixin` | +| `packages/react/src/presets/video/skin.tsx` | Thread `translations` + `translationKeys` props to `I18nProvider` | +| `packages/react/src/presets/video/i18n.ts` | New — `VideoSkinTranslations`, `VIDEO_SKIN_TRANSLATION_KEYS`, skin hooks via `createI18nHooks()` | +| `packages/html/src/define/video/i18n.ts` | New — `VIDEO_SKIN_TRANSLATION_KEYS` for HTML skin element | diff --git a/internal/design/i18n/decisions.md b/internal/design/i18n/decisions.md new file mode 100644 index 000000000..17086376c --- /dev/null +++ b/internal/design/i18n/decisions.md @@ -0,0 +1,223 @@ +--- +status: draft +date: 2026-03-25 +--- + +# Design Decisions + +--- + +## English strings as translation keys + +**Decision.** Translation keys are the English strings themselves. `t('Play')` not `t(TranslationKey.PLAY)`. + +**Alternatives considered.** + +- **Opaque enum** — `t(TranslationKey.PLAY)` or `t('button.play')`. Separates identity from display value; renaming the English string is not a breaking change. +- **Numeric keys** — auto-generated integers. Compact, stable, but completely unreadable at the call site. +- **Namespaced paths** — `t('controls.play')`. Common in i18next; makes nesting easy but adds indirection. + +**Rationale.** English-as-key gives automatic fallback for any missing translation with zero configuration — `t('Play')` returns `'Play'` if the translations object has no `Play` entry. It is self-documenting (`t('Seek forward {seconds} seconds')` is immediately clear). The tradeoff — renaming a key is a breaking change for existing translation files — is acceptable because these are stable UI labels that rarely change. `Translations` as a TypeScript interface catches key mismatches at compile time, reducing the cost of any future rename. + +This pattern is established in Video.js v7/8, GNU gettext, and Flutter intl. + +--- + +## `Translations` is a structural base only — named keys live in each skin + +**Decision.** `Translations` in `@videojs/core/i18n` carries only the index signature (`[key: string]: string | undefined`). Each skin defines its own interface that extends `Translations` and declares exactly the keys its UI uses (including standard player keys like `'Play'`, `'Pause'`, and any skin-specific keys like `'Skip Intro'`). + +**Alternatives considered.** + +- **Named keys in `Translations`** — a shared `CoreTranslations` (or `Translations`) interface with all standard player keys. Any skin that does not use every key carries dead weight in its type. Adding a new core key is a breaking change to the shared interface. +- **No extensibility** — skins always use a fixed `Translations` type. Skin-specific keys like `'Skip Intro'` or `'Cast to {device}'` have no type safety; callers pass arbitrary strings. + +**Rationale.** Moving named keys to each skin gives full TypeScript autocomplete and compile-time key safety scoped to that skin, without locking any skin into a shared definition it did not opt into. The structural base (`Translations` = index signature) is the only contract the core cares about — `createTranslator` accepts any extension of it. The HTML layer uses `Translator` directly (the open index signature) because HTML custom elements call `t()` with string literals derived from element attributes; no narrower type is needed there. This separation also means adding a new key to the Video skin requires no changes to `@videojs/core`. + +--- + +## `createTranslator` factory, not a class or singleton + +**Decision.** A plain factory function returning a closure. No class, no module-level state. + +**Alternatives considered.** + +- **`Translator` class** with a `translate()` method. Familiar to OO consumers; supports subclassing. +- **Module-level singleton** — `setLanguage(translations)` sets a global translator. Simple API but incompatible with multiple players on the same page. +- **`new Translator(translations)`** — same as a class but more explicit about instantiation. + +**Rationale.** A factory function is stateless at the module level — safe for SSR and for multiple players on the same page using different languages simultaneously. It composes naturally with React `useMemo` (recreate when `translations` reference changes) and with `ContextProvider.setValue()` in the HTML layer. No `this`, no `new`, no inheritance chain to thread through call sites. + +--- + +## `createI18nHooks()` factory, not direct exports + +**Decision.** `@videojs/react/i18n` exports a `createI18nHooks()` factory. Each skin calls it once with its own translation type to get `{ I18nContext, I18nProvider, useTranslator, useLocale }` — all typed to `T`. + +**Alternatives considered.** + +- **Direct exports typed to `Translations`** — `I18nProvider`, `useTranslator`, and `useLocale` exported once. Works when all skins share a fixed type; breaks as soon as a skin adds its own key (e.g., `'Skip Intro'`) — the key has no type safety, and `useTranslator()` would return `Translator` which accepts any string. +- **`useTranslator()`** with a type argument at the call site — no factory, but every component call would need an explicit generic: `useTranslator()`. Verbose and easy to forget; the context is still untyped at the React level. +- **Module augmentation** — skins augment the `Translations` interface to add their keys. Requires skins to patch a core module, creating global state in the type system. Not safe when multiple skins with different key sets are used together. + +**Rationale.** The factory pattern closes over a typed React context, so `useTranslator()` within a skin's component tree returns `Translator` with no explicit type annotation at the call site. Each skin creates its own context instance — two skins on the same page each get their own fully-typed context, naturally isolated. The pattern mirrors `createPlayer` (skins already call this with their feature set); `createI18nHooks()` follows the same shape and mental model. + +--- + +## `{param}` interpolation, not ICU message format + +**Decision.** Simple `{key}` replacement, as in `t('Seek forward {seconds} seconds', { seconds: 10 })`. + +**Alternatives considered.** + +- **ICU message format** — `{count, plural, one {# second} other {# seconds}}`. Full pluralization, gender, select. Requires a runtime library (formatjs, etc., ~20 KB min+gz). +- **Template literals via a tagged function** — `t\`Seek forward ${seconds} seconds\``. No library needed but harder to extract keys for translation files. +- **Positional args** — `t('Seek forward %s seconds', 10)`. Compact but brittle when argument order differs between languages. + +**Rationale.** The player has a small, known set of interpolated strings; none require plural rules at the interpolation site. Pluralization for time units is handled by `Intl.PluralRules` via the `pluralize` utility (or automatically by `Intl.DurationFormat`), not at the `t()` call site. `{key}` replacement adds zero runtime weight, is readable, and is the pattern established in Video.js v8/9. + +--- + +## Native `Intl` APIs for locale-aware formatting + +**Decision.** Use `Intl.DurationFormat` for time phrases, `Intl.NumberFormat` with `style: 'percent'` for volume values, and `Intl.PluralRules` (via `pluralize`) for plural selection — with translation-key fallbacks for environments that lack `Intl.DurationFormat`. + +**Alternatives considered.** + +- **Translation keys only** — always build phrases from translated unit words (`'hour'`, `'hours'`, etc.), never use `Intl`. Simpler, but requires translators to supply every unit word and gets locale-specific ordering wrong (e.g., some languages put the larger unit last). +- **`Intl` only, no fallback** — drop the translation-key path entirely. Cleaner, but `Intl.DurationFormat` was only universally available from 2024; some environments (older Safari, Node.js without full ICU) may not have it. +- **ICU message format library** — a runtime library (formatjs, etc.) that handles plurals and durations in one model. Adds ~20 KB and a non-trivial API surface for what are ultimately a small number of phrases. + +**Rationale.** `Intl.DurationFormat` became baseline across Chrome, Firefox, and Safari in 2024. Where available it produces correct locale-specific duration strings — unit labels, pluralization, and ordering — without any translation keys. The `translate`-callback fallback covers older environments and gives translators control over unit words when `Intl` is absent. `Intl.PluralRules` (via `pluralize`) is universally available and eliminates the need to ship separate singular/plural keys per unit in the fallback path. `Intl.NumberFormat` with `style: 'percent'` replaces the `'{value} percent'` and `'{value} percent, muted'` translation keys entirely — it produces the correct symbol and digit forms per locale (`75%`, `٧٥٪`, `75 %`) with no translator involvement. The only remaining volume key is `'muted'`, used as a suffix when the slider is muted. Together these avoid reimplementing what the platform already provides while keeping the bundle impact at zero. + +--- + +## Translation applied at the UI layer, not inside Core classes + +**Decision.** `PlayButtonCore.getLabel()`, `MuteButtonCore.getLabel()`, etc. continue returning English strings. React components and HTML custom elements apply `t()` before setting `aria-label`. + +**Alternatives considered.** + +- **Pass `Translator` into each Core class** — via constructor or `setProps({ t })`. Core is the single source of truth for all string production. +- **Add `translate?` param to `getLabel` and `getAttrs`** — Core calls `translate?.('Play') ?? 'Play'` internally. + +**Rationale.** Core is runtime-agnostic and framework-independent. Threading a context-derived translator into Core would create implicit coupling to whichever context system the caller uses (React context, DOM context, or a test double). The UI layer already holds the translator and is the natural place to apply it — it is the same layer that decides which attributes to set on the element. Core producing the key and the UI layer calling `t(key)` keeps the layers clean. + +The one exception is `formatDuration` in `@videojs/utils`, which assembles the time phrase internally and cannot expose individual words for the UI layer to translate. It receives an optional generic callback instead (see the `formatDuration` decision below). + +--- + +## Parametric cores return the template key, not the resolved string + +**Decision.** `SeekButtonCore.getLabel(state)` returns `'Seek forward {seconds} seconds'` (the template key). A new `SeekButtonCore.getLabelParams(state)` returns `{ seconds: number }`. The UI calls `t(key, params)`. + +**Alternatives considered.** + +- **Keep current behavior** — return the resolved English string `'Seek forward 10 seconds'`. The UI cannot call `t()` because it only has the resolved string, not the key. +- **Pass translator into Core** — Core calls `translate('Seek forward {seconds} seconds', { seconds })` internally. Ruled out by the previous decision. +- **Separate `getLabelKey()` + `getLabelParams()` pair** — same as the chosen approach but different naming. Chosen as cleaner than returning a tuple from `getLabel`. + +**Rationale.** The UI layer needs the unresolved template key to look up a translation. A resolved string (`'Seek forward 10 seconds'`) cannot be used as a lookup key because the number varies. Returning the template key from `getLabel` is a minor behavioral change, but it only affects direct Core consumers (skin authors building their own UI) — they receive a template string that renders naturally when passed through `t()` with the identity translator or used directly. + +Only `SeekButtonCore` and `PlaybackRateButtonCore` have interpolated labels. All other cores are unaffected. + +--- + +## `locale` is a separate prop from `translations`; exposed on the translator + +**Decision.** `I18nProvider` accepts both `locale?: Locale` and `translations?: Translations`. The created translator carries `locale` as a property (`t.locale`), making it accessible without a second context or hook. `useLocale()` is a convenience hook that reads `t.locale` from the nearest `I18nProvider`. + +**Alternatives considered.** + +- **Derive locale from `navigator.language`** — zero configuration, but `navigator.language` doesn't exist during SSR. Using it on the client after SSR causes hydration mismatches if the server used a different default. +- **Separate `useLocale()` context** — a second context alongside the translator context. Consumers would call two hooks and two providers would need to be in sync. Carrying `locale` on the translator itself avoids this at the cost of making `Translator` a callable object rather than a plain function. +- **`locale` inside `translations` object** — `translations.$locale = 'es'`. Unusual convention; mixes metadata with content. +- **Infer locale from the `lang` attribute on ``** — readable on client via `document.documentElement.lang`. Server-rendered HTML sets this correctly, but reading it from the DOM is implicit and fragile if the player is embedded in an iframe or shadow DOM. + +**Rationale.** Passing `locale` explicitly matches how React Aria (``) and MUI (`adapterLocale="de"`) handle it. It keeps `Intl` API calls deterministic across server and client — the only way to avoid hydration mismatches caused by `Intl` formatting differences. Carrying `locale` on the translator function means components that need it for `formatDuration` (time slider, time display) don't need a separate `useLocale()` call; `useLocale()` is a thin wrapper for callers that prefer named access. + +--- + +## Separate `i18nContext` for HTML, not extending `playerContext` + +**Decision.** A new `i18nContext` is created alongside `playerContext`, `mediaContext`, and `containerContext`. + +**Alternatives considered.** + +- **Add `translator` to `PlayerContextValue`** — one context for everything. Requires threading a translation type `T` through `createPlayer`, `PlayerStore`, `PlayerContextValue`, and every `PlayerController` — a large refactor touching core infrastructure. +- **Module-level global translator** — `setGlobalTranslator(createTranslator(translations))`. Simple API, but incompatible with multiple players on the same page using different languages. +- **Property polling** — child elements call `this.closest('[translations]')?.translator`. Fragile; breaks with shadow DOM. + +**Rationale.** `playerContext` is typed by the store's feature set. Adding a translation type would require changes across the entire store and controller infrastructure with no other benefit. A module-level global breaks the multi-player case. A separate context follows the existing pattern (`alertDialogContext`, `sliderContext`, `tooltipContext`) and has zero impact on the store type or the controller API. It is also independently testable. + +--- + +## `I18nMixin` on the skin element, not a separate provider element + +**Decision.** `VideoSkinElement.translations` triggers re-provision to all descendants via `I18nMixin`. + +**Alternatives considered.** + +- **Separate `` custom element** — user adds it to the DOM wrapping the skin. More explicit but requires an extra element in the markup. +- **Imperative `setTranslations()` on the player instance** — consistent with how some other player APIs work. Can be added later as a thin wrapper over `skinElement.translations = ...`. + +**Rationale.** The skin element is already the root that owns all child UI elements. Making it the translation provider means there is exactly one place to configure translations per player instance. No extra DOM element required. An imperative `setTranslations()` API can be added later as a one-liner that assigns `this.translations`. + +--- + +## `Locale` type — loose autocomplete over a known set + +**Decision.** Define `BuiltInLocale` as a union of the locale tags for which built-in packs ship, then `Locale = BuiltInLocale | (string & {})`. Use `Locale` everywhere `locale` appears in the public API (`createTranslator`, `Translator.locale`, `I18nProvider` props, `I18nMixin` property). + +**Alternatives considered.** + +- **Plain `string`** — no autocomplete, no documentation of which locales have first-class support. +- **Strict `BuiltInLocale` union only** — rejects valid BCP 47 tags like `'pt-BR'` or `'zh-TW'` that have no built-in pack. Too restrictive; consumers would need a cast for every regional variant. +- **Branded/opaque type** — `string & { readonly _locale: never }`. Type-safe at runtime boundaries but requires a cast or helper at every consumer call site. High friction. +- **`Intl.BCP47LanguageTag`** — TypeScript's own type for BCP 47 tags. At the time of writing this is aliased to `string`, so it provides no additional safety. + +**Rationale.** `BuiltInLocale | (string & {})` is the established TypeScript pattern for "known autocomplete values, open to others" — used in MUI (`palette.mode`), Radix, and CSS-in-JS libraries for property values. The `string & {}` operand prevents TypeScript from simplifying the union to `string`, which would suppress autocomplete for the named members. The result: editors show a dropdown with the 13 built-in locales when the prop is typed, but `'pt-BR'` or any other BCP 47 tag passes without complaint. `BuiltInLocale` is also useful as a standalone export for runtime checks (`value is BuiltInLocale`). `TimeFormatOptions.locale` and `pluralize`'s third argument stay as `string` because those live in `@videojs/utils`, which cannot import from `@videojs/core`. + +--- + +## Browser Translation API activated only when model is pre-installed + +**Decision.** Use `Translator.availability()` to check readiness before creating a translator instance. Only proceed when it returns `'available'` — the on-device model is already present and there is no network cost. Return an empty object (fall back to English) for all other states: `'downloadable'`, `'downloading'`, `'unavailable'`. + +**Alternatives considered.** + +- **Allow `'downloadable'`** — trigger a model download when the locale pack is absent and the API is supported. Gives coverage for more locales but silently downloads ~100 MB without consumer awareness. +- **Explicit opt-in prop** — add `useBrowserTranslation?: boolean` to the skin. Zero surprise, but adds API surface and makes the common case require extra configuration. +- **Skip the browser API entirely** — rely solely on built-in packs and consumer `translations`. Simpler, but wastes a free signal in browsers that already have the model. + +**Rationale.** An unexpected 100 MB background download is a significant side effect that consumers have no way to anticipate or control. `'available'` means the model is already there — the cost is zero and the benefit is real. Restricting to this state makes the feature a silent bonus rather than a footgun. If demand emerges for the `'downloadable'` path, it can be added as an explicit opt-in prop without changing the default behavior. + +In practice, the likelihood of `'available'` being true is low. For it to fire, the user needs Chrome 138+ desktop (no mobile, no Firefox, no Safari), the origin trial to be enabled, and the `en → {locale}` model to already be downloaded — which only happens if they have previously used Chrome's built-in page translation for that specific language pair in that direction. The users most likely to benefit (non-English speakers with their browser set to their native language) do have some alignment with this: Chrome auto-downloads translation models based on browsing patterns. But the intersection of the three requirements is still a small fraction of any real audience, skewed toward specific high-traffic language pairs (`en → es`, `en → zh`, `en → ja`). This constraint is intentional — the integration adds near-zero code complexity, and the feature is worth having for the cases where it does fire, but it should not be treated as a reliable translation mechanism. The built-in locale packs are the primary story. + +--- + +## Built-in locale packs, lazy-loaded + +**Decision.** Ship JSON translation files for common languages in `@videojs/core/i18n/locales/`. Load them dynamically when `locale` changes; merge with consumer `translations` (consumer always wins). + +**Alternatives considered.** + +- **Eager bundle** — all locales in one module or imported statically. Adds KBs to every consumer even if they only use English. +- **Consumer-only** — no built-in translations; every consumer must supply all strings. High friction for the common case. +- **Separate `@videojs/translations` package** — cleaner independent versioning, but requires an extra install step and creates a second package to maintain. + +**Rationale.** Dynamic `import()` keeps the default bundle size identical to the translation-free baseline — consumers who never set `locale` pay nothing. The merge priority (built-in → consumer) means any key in `translations` always wins, so built-in packs can never override intentional consumer choices. Consumers who need deterministic first-render output (SSR, no flash-of-English) can pre-import the JSON and pass it as `translations` directly, at which point the lazy load is a no-op. + +--- + +## `formatDuration` wraps `Intl.DurationFormat` with an optional `translate` callback + +**Decision.** Add an optional second parameter `translate?: (key: string) => string` to the existing function. Falls back to hardcoded English when absent. + +**Alternatives considered.** + +- **Export a separate `formatTimePhraseTranslatable(seconds, t)`** — explicit API but creates two functions that must stay in sync. +- **Move `formatDuration` to `@videojs/core`** — can then import `Translator` directly. But `@videojs/utils` is lower in the dependency hierarchy than `@videojs/core`; moving the function would flip that relationship. +- **Always require a translator** — callers with no i18n needs must pass `createTranslator()` explicitly. + +**Rationale.** An optional parameter preserves full backward compatibility — existing callers are unaffected. The callback type `(key: string) => string` is intentionally generic; it is satisfied by any `Translator` without importing the type from `@videojs/core`, avoiding a circular dependency. When the callback is absent the existing English behavior is unchanged. diff --git a/internal/design/i18n/index.md b/internal/design/i18n/index.md new file mode 100644 index 000000000..b58cb4699 --- /dev/null +++ b/internal/design/i18n/index.md @@ -0,0 +1,256 @@ +--- +status: draft +date: 2026-03-25 +--- + +# Internationalization (i18n) + +A lightweight, tree-shakeable system for translating player UI strings across React, HTML custom elements, and utility formatters. + +## Contents + +| Document | Purpose | +| -------- | ------- | +| [index.md](index.md) | Overview, design goals, quick start, Translations reference | +| [architecture.md](architecture.md) | Core types, React hooks, HTML context, utils integration | +| [decisions.md](decisions.md) | Design rationale | + +## 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 for callers to supply translated strings without overriding each component's `label` prop individually, and no single-point language switch for skins. + +## Design Goals + +- **Single entry point** — one `translations` object replaces all English defaults. +- **English as key** — translation keys _are_ the English defaults; missing keys fall back gracefully with no configuration. +- **Typed keys** — each skin defines its own `Translations` extension; editors autocomplete every key including skin-specific ones. +- **Extensible** — skins define their own translation interface with full TypeScript key safety; `createI18nHooks()` produces hooks scoped to that type. +- **Framework-agnostic core** — `createTranslator` lives in `@videojs/core/i18n` with no DOM or React imports. +- **Locale-aware** — native `Intl` APIs (`DurationFormat`, `NumberFormat`, `PluralRules`) handle duration formatting, number rendering, and plural selection where available; translation keys cover the fallback path for older environments. +- **No breaking changes to `label` props** — existing per-component overrides keep working; `t()` is the new default path. +- **Tree-shakeable** — unused translation infrastructure adds nothing to bundles that do not import it. + +## Anatomy + +### React + +The consumer API is a `locale` and `translations` prop directly on the skin. Each skin defines its own translation interface — the Video skin's is `VideoSkinTranslations`, which includes all the standard player keys plus any video-specific additions: + +```tsx +import { VideoSkin } from '@videojs/react/video'; + + +``` + +`I18nProvider` and `Provider` are internal to the skin — consumers never touch them directly (see [architecture.md](architecture.md)). + +```tsx +// Inside any component +function PlayButton() { + const t = useTranslator(); + const label = ended ? t('Replay') : paused ? t('Play') : t('Pause'); +} +``` + +A skin that adds its own keys (e.g. `'Skip Intro'`) simply extends `Translations` in its own package — no changes to core required: + +```ts +// packages/react/src/presets/video/i18n.ts +import type { Translations } from '@videojs/core/i18n'; +import { createI18nHooks } from '@videojs/react/i18n'; + +export interface VideoSkinTranslations extends Translations { + Play?: string; + Pause?: string; + // ... all standard player keys ... + 'Skip Intro'?: string; // video-skin-specific +} + +export const { I18nProvider, useTranslator, useLocale } = + createI18nHooks(); +``` + +### HTML + +```ts +// Set via property (object) +skinElement.translations = { Play: 'Reproducir', Pause: 'Pausa' }; +``` + +```html + + +``` + +## Consumer Usage + +### Static translations + +```tsx +import { VideoSkin } from '@videojs/react/video'; +import { esTranslations } from './locales/es'; + + +``` + +### Built-in translations + +Video.js ships locale packs for common languages. Set `locale` and the matching pack loads automatically — no `translations` prop needed: + +```tsx +// Built-in Spanish translations load lazily on mount + +``` + +The pack is loaded via dynamic `import()` so it has zero impact on the default bundle. If no pack exists for the requested locale, the player also checks the browser's built-in [Translator API](https://developer.chrome.com/docs/ai/translator-api) (Chrome 138+). If the translation model is already downloaded — no network request is triggered — player strings are auto-translated in the background. Built-in packs and consumer `translations` always take priority over browser-translated strings. If neither is available, the player falls back to English. + +Consumer-provided `translations` always take priority over built-in packs: + +```tsx +// Built-in "es" pack loads, then "Play" is overridden by the consumer value + +``` + +For SSR or when you want zero flash-of-English on first render, pre-import the JSON and pass it directly: + +```tsx +import es from '@videojs/core/i18n/locales/es.json'; + + +``` + +### Dynamic language switching + +```tsx +function App() { + const [locale, setLocale] = useState('en'); + const [translations, setTranslations] = useState({}); + + async function changeLocale(next: string) { + const { default: t } = await import(`./locales/${next}.json`); + setTranslations(t); + setLocale(next); + } + + return ; +} +``` + +### With next-intl (App Router, SSR-safe) + +```tsx +// app/[locale]/page.tsx — server component +import { getLocale, getTranslations } from 'next-intl/server'; +import { VideoSkin } from '@videojs/react/video'; + +export default async function Page() { + const locale = await getLocale(); // from URL routing, e.g. /es/watch + const t = await getTranslations('player'); // from messages/es.json + + return ( + + ); +} +``` + +### With react-i18next + +```tsx +function Page() { + const { t, i18n } = useTranslation('player'); + + return ( + + ); +} +``` + +### HTML + +```html + + +``` + +```ts +// Or via property (object) +skinElement.locale = 'es'; +skinElement.translations = { Play: 'Reproducir', Pause: 'Pausa' }; +``` + +## Locale-based caption selection + +When `locale` is set and the media has subtitle or caption tracks, the player automatically activates the track whose language best matches the locale. The user does not need to manually enable captions. + +```tsx +// locale="es" → player auto-enables the Spanish subtitle track if one is present + +``` + +**Matching algorithm** — BCP 47 base language, in priority order: + +1. Exact match — `locale="es-MX"` matches a track with `language: 'es-MX'` +2. Base language match — `locale="es-MX"` matches a track with `language: 'es'`, `'es-ES'`, etc. +3. No match — no track is auto-selected; existing caption state is left unchanged + +**User override** — once the user manually selects or deselects a caption track within the session, locale-based auto-selection backs off and no longer overrides their choice. + +**Locale changes** — if `locale` changes at runtime (dynamic language switch), the player re-evaluates the track list and switches to the best match, unless the user has already made a manual selection. + +See [architecture.md](architecture.md) for implementation details. + +## Video Skin Translations Reference + +`VideoSkinTranslations` is defined in the video skin package. All keys are optional — when a key is absent the English string (the key itself) is used. Other skins define their own translation interface and may use a different set of keys. + +| Key | Default | Params | Used by | +| --- | ------- | ------ | ------- | +| `'Play'` | `'Play'` | — | `PlayButtonCore` | +| `'Pause'` | `'Pause'` | — | `PlayButtonCore` | +| `'Replay'` | `'Replay'` | — | `PlayButtonCore` | +| `'Mute'` | `'Mute'` | — | `MuteButtonCore` | +| `'Unmute'` | `'Unmute'` | — | `MuteButtonCore` | +| `'Enter fullscreen'` | `'Enter fullscreen'` | — | `FullscreenButtonCore` | +| `'Exit fullscreen'` | `'Exit fullscreen'` | — | `FullscreenButtonCore` | +| `'Enable captions'` | `'Enable captions'` | — | `CaptionsButtonCore` | +| `'Disable captions'` | `'Disable captions'` | — | `CaptionsButtonCore` | +| `'Enter picture-in-picture'` | `'Enter picture-in-picture'` | — | `PipButtonCore` | +| `'Exit picture-in-picture'` | `'Exit picture-in-picture'` | — | `PipButtonCore` | +| `'Seek'` | `'Seek'` | — | `TimeSliderCore` (aria-label) | +| `'Volume'` | `'Volume'` | — | `VolumeSliderCore` (aria-label) | +| `'Current time'` | `'Current time'` | — | `TimeCore` | +| `'Duration'` | `'Duration'` | — | `TimeCore` | +| `'Remaining'` | `'Remaining'` | — | `TimeCore` | +| `'Seek forward {seconds} seconds'` | same | `{seconds}` | `SeekButtonCore` | +| `'Seek backward {seconds} seconds'` | same | `{seconds}` | `SeekButtonCore` | +| `'Playback rate {rate}'` | same | `{rate}` | `PlaybackRateButtonCore` | +| `'muted'` | `'muted'` | — | `VolumeSliderCore` (aria-valuetext suffix) | +| `'{current} of {duration}'` | same | `{current}`, `{duration}` | `TimeSliderCore` (aria-valuetext) | +| `'remaining'` | `'remaining'` | — | `formatDuration` (negative time suffix) | + +> **Note on `'{current} of {duration}'`:** The params are already-formatted time phrases (e.g., `'2 Minuten, 30 Sekunden'` from `Intl.DurationFormat`), not raw numbers. + +> **Why no time unit or percent keys?** `Intl.DurationFormat` handles unit labels and `Intl.NumberFormat` with `style: 'percent'` handles percentage formatting — both locale-correctly, with no translation keys. Only `'remaining'` and `'muted'` are needed because `Intl` has no concept of those suffixes. + +## Related Docs + +- [architecture.md](architecture.md) — Implementation details for each layer +- [decisions.md](decisions.md) — Design rationale