diff --git a/internal/design/i18n.md b/internal/design/i18n.md
index 121c933e..5c1f1174 100644
--- a/internal/design/i18n.md
+++ b/internal/design/i18n.md
@@ -1,5 +1,5 @@
---
-status: draft
+status: implemented
date: 2026-03-25
---
@@ -162,10 +162,10 @@ const { default: translations } = await import(`@videojs/react/i18n/locales/${lo
**Dynamic switching**
-Just flip `locale` — the provider lazy-loads the built-in pack for the new locale:
+Changing locale loads the shipped pack automatically via `loadLocale` (or pass `translations` / call `registerI18n` for zero-flash SSR):
```tsx
-const [locale, setLocale] = useState('en');
+const [locale, setLocale] = useState('es');
@@ -176,7 +176,7 @@ const [locale, setLocale] = useState('en');
```
-Or for zero-flash switching, pre-import the locale and pass `translations` directly:
+For zero-flash switching, pre-import the locale and pass `translations` directly:
```tsx
const [{ locale, translations }, setLocale] = useState({ locale: 'en', translations: undefined });
@@ -215,27 +215,55 @@ Keys are opaque camelCase identifiers. The English string is the value in `en.ts
| `replay` | `'Replay'` | — | `PlayButtonCore` |
| `mute` | `'Mute'` | — | `MuteButtonCore` |
| `unmute` | `'Unmute'` | — | `MuteButtonCore` |
-| `seek` | `'Seek'` | — | `TimeSliderCore` (aria-label) |
-| `volume` | `'Volume'` | — | `VolumeSliderCore` (aria-label) |
-| `muted` | `'muted'` | — | `VolumeSliderCore` (aria-valuetext suffix) |
+| `seekForward` | `'Seek forward {seconds} seconds'` | `{seconds}` | `SeekButtonCore` |
+| `seekBackward` | `'Seek backward {seconds} seconds'` | `{seconds}` | `SeekButtonCore` |
| `enterFullscreen` | `'Enter fullscreen'` | — | `FullscreenButtonCore` |
| `exitFullscreen` | `'Exit fullscreen'` | — | `FullscreenButtonCore` |
| `enableCaptions` | `'Enable captions'` | — | `CaptionsButtonCore` |
| `disableCaptions` | `'Disable captions'` | — | `CaptionsButtonCore` |
| `enterPictureInPicture` | `'Enter picture-in-picture'` | — | `PipButtonCore` |
| `exitPictureInPicture` | `'Exit picture-in-picture'` | — | `PipButtonCore` |
-| `currentTime` | `'Current time'` | — | `TimeCore` |
-| `duration` | `'Duration'` | — | `TimeCore` |
-| `remaining` | `'Remaining'` | — | `TimeCore` |
-| `seekForward` | `'Seek forward {seconds} seconds'` | `{seconds}` | `SeekButtonCore` |
-| `seekBackward` | `'Seek backward {seconds} seconds'` | `{seconds}` | `SeekButtonCore` |
-| `playbackRate` | `'Playback rate {rate}'` | `{rate}` | `PlaybackRateButtonCore` |
-| `timePosition` | `'{current} of {duration}'` | `{current}`, `{duration}` | `TimeSliderCore` (aria-valuetext) |
-| `remaining` | `'remaining'` | — | `formatDuration` (negative time suffix) |
+| `playingLive` | `'Playing live'` | — | `LiveButtonCore` |
+| `seekToLiveEdge` | `'Seek to live edge'` | — | `LiveButtonCore` |
+| `liveBadge` | `'Live'` | — | Live badge / time display |
+| `startCasting` | `'Start casting'` | — | `CastButtonCore` |
+| `stopCasting` | `'Stop casting'` | — | `CastButtonCore` |
+| `connectingCast` | `'Connecting'` | — | `CastButtonCore` |
+| `seek` | `'Seek'` | — | `TimeSliderCore` (aria-label) |
+| `volume` | `'Volume'` | — | `VolumeSliderCore` (aria-label) |
+| `timeCurrent` | `'Current time'` | — | `TimeCore` |
+| `timeDuration` | `'Duration'` | — | `TimeCore` |
+| `timeRemaining` | `'Remaining'` | — | `TimeCore` |
+| `remainingTimeSuffix` | `'remaining'` | — | `formatDuration` (negative time suffix) |
+| `playbackRateAria` | `'Playback rate {rate}'` | `{rate}` | `PlaybackRateButtonCore`, playback-rate menu |
+| `timeSliderValueTextRange` | `'{current} of {duration}'` | `{current}`, `{duration}` | `TimeSliderCore` (aria-valuetext) |
+| `volumeSliderValueTextMuted` | `'{percent}, muted'` | `{percent}` | `VolumeSliderCore` (aria-valuetext when muted) |
+| `indicatorMuted` | `'Muted'` | — | Input feedback (status / announcer) |
+| `indicatorVolume` | `'Volume'` | — | Input feedback (status indicator label) |
+| `indicatorVolumeWithValue` | `'Volume {value}'` | `{value}` | Input feedback (status announcer) |
+| `indicatorCaptionsOn` | `'Captions on'` | — | Input feedback |
+| `indicatorCaptionsOff` | `'Captions off'` | — | Input feedback |
+| `indicatorPaused` | `'Paused'` | — | Input feedback |
+| `indicatorPlaying` | `'Playing'` | — | Input feedback |
+| `indicatorFullscreen` | `'Enter fullscreen'` | — | Input feedback |
+| `indicatorExitFullscreen` | `'Exit fullscreen'` | — | Input feedback |
+| `indicatorPictureInPicture` | `'Picture in picture'` | — | Input feedback |
+| `indicatorExitPictureInPicture` | `'Exit picture in picture'` | — | Input feedback |
+| `mediaErrorAborted` | `'You aborted the media playback'` | — | Error dialog |
+| `mediaErrorNetwork` | `'A network error caused…'` | — | Error dialog |
+| `mediaErrorDecode` | `'A media error caused playback…'` | — | Error dialog |
+| `mediaErrorSrcNotSupported` | `'An unsupported error occurred…'` | — | Error dialog |
+| `mediaErrorEncrypted` | `'The media is encrypted…'` | — | Error dialog |
+| `mediaErrorCustom` | `''` | — | Error dialog (custom errors use literal message) |
+| `errorDialogTitle` | `'Something went wrong.'` | — | Error dialog |
+| `errorDialogDismiss` | `'OK'` | — | Error dialog |
+| `mediaErrorFallback` | `'An error occurred. Please try again.'` | — | Error dialog |
-> `timePosition` params are already-formatted time phrases from `Intl.DurationFormat`, not raw numbers.
+> `timeSliderValueTextRange` params are already-formatted time phrases from `Intl.DurationFormat`, not raw numbers.
-> `Intl.DurationFormat` handles all duration unit labels; `Intl.NumberFormat` handles percent formatting. Only `muted` and `remaining` are translation keys because `Intl` has no concept of those suffixes.
+> `Intl.DurationFormat` handles duration unit labels; `Intl.NumberFormat` handles percent formatting. Only `remainingTimeSuffix` and `volumeSliderValueTextMuted` need translation keys for suffixes `Intl` cannot express.
+
+> Full key list and param contracts: `packages/core/src/core/i18n/types.ts` (`TranslationParams`).
## Architecture
@@ -263,82 +291,15 @@ Keys are opaque camelCase identifiers. The English string is the value in `en.ts
### Core types
-```ts
-// @videojs/core/i18n/types.ts
+Authoritative definitions: `packages/core/src/core/i18n/types.ts` and `built-in-locales.ts`.
-export type BuiltInLocale =
- | 'ar' | 'de' | 'es' | 'fr' | 'it' | 'ja'
- | 'ko' | 'nl' | 'pl' | 'pt' | 'ru' | 'tr' | 'zh';
+- **`BuiltInLocale`** — autocomplete for shipped packs (`BUILT_IN_LOCALES`: 50 tags + `LOCALE_ALIAS_TAGS`: `pt`, `zh`).
+- **`Locale`** — `BuiltInLocale | (string & {})` for any BCP 47 tag.
+- **`TranslationParams`** — per-key param contract (`never` = no params).
+- **`Translations`** — optional overlay; parametric values must contain required `{placeholder}` substrings (`Contains<>` helper).
+- **`Translator`** — typed `(key, params?) => string` from `createTranslator`.
-/** Any BCP 47 tag; named built-in locales autocomplete in editors. */
-export type Locale = BuiltInLocale | (string & {});
-
-/** Helper — string type that must contain a given literal substring. */
-type Contains = `${string}${Needle}${string}`;
-
-/** Per-key parameter contract — maps each key to the exact params it requires (or `never`). */
-export interface TranslationParams {
- play: never;
- pause: never;
- replay: never;
- mute: never;
- unmute: never;
- seek: never;
- volume: never;
- muted: never;
- enterFullscreen: never;
- exitFullscreen: never;
- enableCaptions: never;
- disableCaptions: never;
- enterPictureInPicture: never;
- exitPictureInPicture: never;
- currentTime: never;
- duration: never;
- remaining: never;
- seekForward: { seconds: string | number };
- seekBackward: { seconds: string | number };
- playbackRate: { rate: string | number };
- timePosition: { current: string | number; duration: string | number };
-}
-
-/** All player translation keys. All keys are optional — missing keys fall back to English.
- * Value types enforce the `{param}` placeholders required by `TranslationParams`. */
-export interface Translations {
- play?: string;
- pause?: string;
- replay?: string;
- mute?: string;
- unmute?: string;
- seek?: string;
- volume?: string;
- muted?: string;
- enterFullscreen?: string;
- exitFullscreen?: string;
- enableCaptions?: string;
- disableCaptions?: string;
- enterPictureInPicture?: string;
- exitPictureInPicture?: string;
- currentTime?: string;
- duration?: string;
- remaining?: string;
- seekForward?: Contains<'{seconds}'>;
- seekBackward?: Contains<'{seconds}'>;
- playbackRate?: Contains<'{rate}'>;
- timePosition?: Contains<'{current}'> & Contains<'{duration}'>;
-}
-
-type SimpleKeys = { [K in keyof TranslationParams]: TranslationParams[K] extends never ? K : never }[keyof TranslationParams];
-type ParamKeys = Exclude;
-
-/** Callable translator. Keys with params require the exact params object; keys without params take no second argument. */
-export type Translator = {
- (key: SimpleKeys): string;
- (key: K, params: TranslationParams[K]): string;
- readonly locale?: Locale;
-};
-```
-
-The `Contains` helper makes locale files compile-error when they forget a `{param}` placeholder. `Translator`'s overloaded signature catches two classes of mistake at call sites:
+The `Contains` helper makes locale files compile-error when they forget a `{param}` placeholder. `Translator`'s signature catches two classes of mistake at call sites:
```ts
t('play'); // ✓ no params
@@ -497,38 +458,28 @@ export function createI18n() {
);
const locale = explicitLocale ?? ambientLocale;
- // Lazy-load built-in pack when locale is set
- const [builtIn, setBuiltIn] = useState>({});
+ // Lazy-load built-in overlays, then browser fallback when no pack exists.
useEffect(() => {
- if (!locale) { setBuiltIn({}); return; }
- const tags = [...new Set([locale, locale.split('-')[0]])];
- const load = (i = 0): Promise =>
- import(`@videojs/core/i18n/locales/${tags[i]}`)
- .then(m => { registerI18n(tags[i], m.default); setBuiltIn(m.default); })
- .catch(() => i + 1 < tags.length ? load(i + 1) : setBuiltIn({}));
- load();
- }, [locale]);
+ const seq = ++lazySeqRef.current;
+ const locale = resolvedLocale;
+ void (async () => {
+ const mergedLazy = await mergeLocaleOverlays(locale, loadLocale, localeLookupChain);
+ if (seq !== lazySeqRef.current) return;
+ if (shouldAttemptBrowserTranslation(locale, mergedLazy)) {
+ const browser = await getBrowserTranslations(locale);
+ if (Object.keys(browser).length) registerI18n(locale, browser);
+ }
+ if (seq !== lazySeqRef.current) return;
+ setLazyLayer(mergedLazy);
+ })();
+ }, [resolvedLocale]);
- // Browser Translation API — background fallback, pre-installed model only.
- // Enumerates the full key set from en.ts (no consumer input needed).
- const [browserTranslated, setBrowserTranslated] = useState>({});
- useEffect(() => {
- if (!locale) { setBrowserTranslated({}); return; }
- let cancelled = false;
- getBrowserTranslations(locale).then(result => {
- if (!cancelled) setBrowserTranslated(result);
- });
- return () => { cancelled = true; };
- }, [locale]);
-
- // Priority: browser API < registry/built-in < consumer translations
- const translator = useMemo(
- () => createTranslator(
- { ...browserTranslated, ...getI18nTranslations(locale ?? 'en'), ...builtIn, ...translations },
- locale
- ),
- [browserTranslated, builtIn, translations, locale]
+ // Priority: registry (incl. browser registerI18n) < lazy built-in < consumer translations
+ const translations = useMemo(
+ () => ({ ...getI18nTranslations(resolvedLocale), ...lazyLayer, ...translationsProp }),
+ [resolvedLocale, lazyLayer, translationsProp, registryEpoch]
);
+ const translator = useMemo(() => createTranslator(translations, resolvedLocale), [translations, resolvedLocale]);
return {children};
}
@@ -647,33 +598,29 @@ const ProviderMixin = >(base: Base) =>
if (changed.has('lang')) this.#refresh();
}
- async #refresh(): Promise {
- const locale = this.#effectiveLocale;
- if (!locale) { this.#updateProvider(undefined); return; }
-
- if (!hasRegisteredI18n(locale)) {
- const tags = [...new Set([locale, locale.split('-')[0]])];
- for (const tag of tags) {
- try {
- const { default: data } = await import(`@videojs/core/i18n/locales/${tag}`);
- registerI18n(tag, data);
- break;
- } catch { /* try next tag */ }
+ async #resetLazyAndLoad(): Promise {
+ const localeSnapshot = resolveProviderLocale(this);
+ this.#lazyResetStartedForLocale = localeSnapshot;
+ this.#lazySeq += 1;
+ const seq = this.#lazySeq;
+ this.#lazyLayer = {};
+ void (async () => {
+ const merged = await mergeLocaleOverlays(localeSnapshot, loadLocale, localeLookupChain);
+ if (seq !== this.#lazySeq) return;
+ if (shouldAttemptBrowserTranslation(localeSnapshot, merged)) {
+ const browser = await getBrowserTranslations(localeSnapshot);
+ if (Object.keys(browser).length) registerI18n(localeSnapshot, browser);
}
- }
-
- // Browser Translation API — background, pre-installed model only
- if (!hasRegisteredI18n(locale) && TRANSLATION_KEYS.length) {
- getBrowserTranslations(locale, TRANSLATION_KEYS).then(result => {
- if (Object.keys(result).length) registerI18n(locale, result);
- });
- }
-
- this.#updateProvider(locale);
+ if (seq !== this.#lazySeq) return;
+ this.#lazyLayer = merged;
+ this.requestUpdate();
+ })();
}
- #updateProvider(locale: Locale | undefined): void {
- this.#provider.setValue(createTranslator(getI18nTranslations(locale ?? 'en'), locale));
+ #publish(): void {
+ const locale = resolveProviderLocale(this);
+ const translations = { ...getI18nTranslations(locale), ...this.#lazyLayer };
+ this.#i18nProvider.setValue({ translator: createTranslator(translations, locale), locale });
}
};
```
@@ -697,10 +644,24 @@ const TextMixin = >(base: Base) =>
};
```
-`` is for any dynamic translated content that isn't already wired to a component state (free-floating tooltip copy, dialog bodies, custom labels in ejected skins). Built-in button elements already auto-forward their `aria-label` from the registry via `I18nController` — no `` wrapping required for those.
+### HTML: Built-in skin tooltips (trigger sync)
+
+Built-in skins use **empty** `` elements linked to controls via `commandfor`. Tooltip text is not authored in the skin template — `TooltipElement` copies the trigger button's translated label at runtime (`getLabel()` / `getResolvedLabel()` via `I18nController`), keeping tooltip copy and `aria-label` in sync.
```html
-
+
+
+
+```
+
+Changing text inside `` in a built-in skin has no effect. To customize tooltip and `aria-label`, set `label` on the control (e.g. ``) or override the component's core label key.
+
+### HTML: `` (ejected / custom skins only)
+
+`` 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 ``.
+
+```html
+
@@ -791,36 +752,39 @@ The Browser Translation API path uses the native `Intl.Locale` minimization wher
### Browser Translation API
-The [Translator API](https://developer.chrome.com/docs/ai/translator-api) (WICG draft, Chrome 138+ origin trial) provides on-device text translation. Video.js uses it as a background fallback for locales with no registered pack.
+The [Translator API](https://developer.chrome.com/docs/ai/translator-api) (WICG draft, Chrome 138+) provides on-device text translation. Video.js uses it as a background fallback for locales with no registered or lazy-loaded pack. Implementation lives in `@videojs/core/i18n` (`getBrowserTranslations`, `shouldAttemptBrowserTranslation`).
-Only activates when `Translator.availability()` returns `'available'` — model already present, no network cost. `'downloadable'` / `'downloading'` / `'unavailable'` are silently skipped.
+Only activates when `Translator.availability()` returns `'available'` — model already present, no network cost. `'downloadable'` / `'downloading'` / `'unavailable'` are silently skipped in production providers.
-Since keys are opaque, the browser API translates the *English values* from `en.ts`, then maps the results back to keys:
+**Sandbox exception.** The dev sandbox may call `getBrowserTranslations` with `downloadIfNeeded: true` for browser-only locale tags (Chrome Translator API languages without a shipped pack). This is for demo coverage only — not production behavior.
-```ts
-const en = getI18nTranslations('en');
-const englishValues = keys.map(k => en[k] ?? k);
-const translated = await Promise.all(englishValues.map(v => translator.translate(v)));
-return Object.fromEntries(keys.map((k, i) => [k, translated[i]]));
-```
+Providers call `shouldAttemptBrowserTranslation(locale, lazyLayer)` after `mergeLocaleOverlays` — skip when locale is English, when lazy built-ins loaded, or when any non-`en` tag in `localeLookupChain(locale)` is already registered (covers `es-MX` → `es`).
-Results are cached by locale at module level — repeated mounts of the same skin do not re-trigger translation.
+Since keys are opaque, the browser API translates the *English values* from `en.ts`, then maps the results back to keys. Results are registered via `registerI18n` (triggering `onI18nRegistryChange`) and cached per target language at module level.
**Priority merge:**
```
English defaults (en.ts — always present)
↑
-Browser API (auto-translated, background, pre-installed only)
+Browser API (registerI18n after async translate; pre-installed model only)
↑
-Registry pack (registerI18n / built-in locale)
+Registry pack (registerI18n / CDN locale modules)
↑
-Consumer prop (translations — always wins)
+Lazy built-in (`loadLocale` overlay from `@videojs/core/i18n`)
+ ↑
+Consumer prop (React translations — always wins)
```
+### `loadLocale`
+
+`loadLocale(tag)` in `@videojs/core/i18n` lazy-imports shipped locale packs by exact BCP 47 tag. It skips tags already present in the registry (including `en`) so explicit `registerI18n` overrides are preserved. Default `createI18n()` providers call it via `mergeLocaleOverlays` when the locale changes — `` or `` loads Spanish without a prior `registerI18n` call. Override with `createI18n({ loadLocale })` in tests or custom apps.
+
+Explicit `registerI18n`, CDN locale modules, and React `translations` still merge on top and remain the preferred path for SSR (zero flash) and CDN.
+
### Intl API integration
-**`Intl.DurationFormat`** — drives `formatDuration`. Handles unit labels, pluralization, and locale-specific ordering automatically (baseline: Chrome 122+, Firefox 127+, Safari 18+). The `remaining` suffix still uses `translate?.('remaining')` since `Intl.DurationFormat` has no concept of remaining time.
+**`Intl.DurationFormat`** — drives `formatDuration`. Handles unit labels, pluralization, and locale-specific ordering automatically (baseline: Chrome 122+, Firefox 127+, Safari 18+). The remaining-time suffix uses `remainingTimeSuffix` via `formatOptions.translate` since `Intl.DurationFormat` has no concept of remaining time.
**`Intl.NumberFormat`** with `style: 'percent'` — formats volume values. No translation key needed.
@@ -888,7 +852,7 @@ registerI18n('es', { play: 'Reproducir', pause: 'Pausa', /* … */ });
- *Side-effect locale imports* — `import '@videojs/core/i18n/locales/es'` auto-registers. Media Chrome uses this pattern (`import 'media-chrome/lang/es.js'`). Simple for CDN, but bundlers may include unwanted locales, and there's no explicit registration call for overrides.
- *Lazy-load only inside provider* — each provider independently loads its pack. Multiple players for the same locale each trigger a separate network request.
-**Rationale.** Module-level singleton means each locale is loaded once per page regardless of how many players are mounted. For CDN, locale files are self-registering ES modules — they import `registerI18n` from the same module URL, sharing the registry instance with no global namespace touch. For bundlers, consumers call `registerI18n` explicitly after importing the JSON. `` can still lazy-load built-in packs as a convenience fallback (triggered only when the locale is not already registered).
+**Rationale.** Module-level singleton means each locale is loaded once per page regardless of how many players are mounted. For CDN, locale files are self-registering ES modules — they import `registerI18n` from the same module URL, sharing the registry instance with no global namespace touch. For bundlers, consumers may call `registerI18n` explicitly or rely on default provider `loadLocale` lazy imports. Pass React `translations` for SSR zero-flash.
### Standalone providers — skins are i18n-unaware
@@ -924,16 +888,27 @@ registerI18n('es', { play: 'Reproducir', pause: 'Pausa', /* … */ });
**Rationale.** The factory pattern allows consumers to create independent provider trees if needed (e.g., two players with different languages and fully isolated contexts). It also keeps the provider and hooks as a cohesive unit — impossible to import `useTranslator` without the matching context.
-### `` for template strings
+### Built-in tooltips: trigger sync, not ``
-**Decision.** A `` element renders translated text inside shadow DOM templates.
+**Decision.** Shipped video/audio/live skins use empty `` + `commandfor`; `TooltipElement` syncs translated text from the linked control's label.
+
+**Alternatives considered.**
+
+- *`` inside each tooltip* — duplicates the control's translation key; two sources of truth for the same string.
+- *`` 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.
+
+### `` for ejected skin template strings
+
+**Decision.** A `` element renders translated text inside shadow DOM templates where no component owns the phrase.
**Alternatives considered.**
-- *`` self-translating* — tooltip knows its translation key. Couples tooltip semantics to i18n; translation key becomes a tooltip API concern.
- *Skin re-renders full template on locale change* — not viable; shadow DOM templates are static HTML cloned once on element creation.
-**Rationale.** `` is the minimal reactive primitive for translated text inside static shadow DOM. It subscribes to `i18nContext` independently and updates only `textContent` — no parent re-render needed. The `key` attribute is the only i18n contract in ejected skin templates, making them easy to audit and override.
+**Rationale.** `` is the minimal reactive primitive for ad-hoc translated copy in custom/ejected skins. It subscribes to `i18nContext` independently and updates only `textContent` — no parent re-render needed.
### `{param}` interpolation, not ICU message format
@@ -945,7 +920,7 @@ registerI18n('es', { play: 'Reproducir', pause: 'Pausa', /* … */ });
**Decision.** `Intl.DurationFormat` for time phrases, `Intl.NumberFormat` with `style: 'percent'` for volume, and `Intl.PluralRules` via `pluralize` for plural selection.
-**Rationale.** `Intl.DurationFormat` became baseline in 2024 and handles unit labels, pluralization, and locale-specific ordering — zero translation keys. `Intl.NumberFormat` handles percent symbols and digit forms. Only `remaining` and `muted` need translation keys because `Intl` has no concept of those suffixes.
+**Rationale.** `Intl.DurationFormat` became baseline in 2024 and handles unit labels, pluralization, and locale-specific ordering — zero translation keys. `Intl.NumberFormat` handles percent symbols and digit forms. Only `remainingTimeSuffix` and `volumeSliderValueTextMuted` need translation keys because `Intl` has no concept of those suffixes.
### Browser Translation API — pre-installed model only
@@ -979,7 +954,7 @@ VJS v8's `addLanguage` is the closest precedent. This design replaces English-as
**`en.ts` bundle footprint.** `en.ts` is imported as a module-level side effect of `@videojs/core/i18n`. Consumers who never use i18n should not import this module — they will not pay the cost if they use only `@videojs/core/ui` or similar.
-**Tooltip keys in ejected skins.** `` inside `` is unusual markup compared to hardcoded text. This is an acceptable tradeoff — the `key` attribute makes the translation contract explicit and the element is thin enough to not be confusing.
+**When to use ``.** Prefer control `label` + trigger-synced tooltips for anything tied to a button. Use `` only for standalone copy in ejected skins (help text, custom dialog bodies) where no component exposes `getLabel()`.
## Descoped
@@ -991,32 +966,33 @@ VJS v8's `addLanguage` is the closest precedent. This design replaces English-as
- **Translator perf optimizations** — `createTranslator` re-parses `{param}` templates on every call via `String.prototype.replace`. Future work, not required for v1:
- *Run-time* — parse each template once on first use, cache tokens keyed by source string, reuse thereafter (similar to i18next's format cache).
- *Build-time* — AOT-compile locale entries into functions (`seekForward: ({ seconds }) => \`Seek forward ${seconds} seconds\``); inline static keys with dead-code elimination for the active locale (similar to`babel-plugin-formatjs` / `lingui`).
-- **Custom translator implementations** — swapping `createTranslator` for a consumer-supplied translator (e.g., to delegate to `i18next` or FormatJS). The current `Translator` type is a callable, so this is feasible, but no public extension point is wired up.
+- **Custom translator implementations** — a consumer-supplied `translate(key) → string` override (e.g. to delegate to `i18next` or FormatJS). Deferred follow-up; use `registerI18n` or React `translations` prop today.
## File Structure
```
packages/
-├── core/src/i18n/
+├── core/src/core/i18n/
│ ├── types.ts ← BuiltInLocale, Locale, Translations, Translator
│ ├── translator.ts ← createTranslator
+│ ├── browser-translation.ts ← getBrowserTranslations, shouldAttemptBrowserTranslation
+│ ├── load-locale.ts ← loadLocale (codegen lazy import map)
│ ├── registry.ts ← registerI18n, getI18nTranslations, onI18nRegistryChange
│ ├── index.ts ← re-exports
│ └── locales/
│ ├── en.ts ← English defaults (pre-registered at module init)
-│ ├── ar.ts … zh.ts ← built-in locale packs
+│ ├── ar.ts … zh.ts ← built-in locale packs (default export each)
+│ ├── all.ts ← aggregated map `{ all, localeTags }` (generated; loads every pack)
│ └── *.cdn.ts ← CDN self-registering entry points (one per locale)
│
├── react/src/i18n/
│ ├── create-i18n.tsx ← createI18n
-│ ├── browser-translation.ts
-│ ├── locales/ ← re-exports of core/locales/*.ts
+│ ├── locales/ ← generated re-exports of core/locales/*.ts
│ └── index.ts ← public entry: registerI18n, I18nProvider, useTranslator, useLocale, Translations, Translator, Locale
│
└── html/src/i18n/
├── create-i18n.ts ← createI18n → { context, I18nController, ProviderMixin, TextMixin }
- ├── browser-translation.ts
- ├── locales/ ← re-exports of core/locales/*.ts
+ ├── locales/ ← generated re-exports of core/locales/*.ts
├── define/
│ ├── media-i18n-provider.ts ← ProviderMixin(ReactiveElement) + customElements.define
│ └── media-text.ts ← TextMixin(ReactiveElement) + customElements.define
@@ -1029,6 +1005,8 @@ packages/utils/src/time/
└── format.ts ← formatDuration, TimeFormatOptions, TimeTranslate
```
+**Adding a locale:** author `packages/core/src/core/i18n/locales/{tag}.ts`, append the tag to `built-in-locales.ts`, then run `pnpm -F @videojs/core generate:locales` (also runs on `@videojs/core` prebuild). That regenerates `locales/all.ts` and html/react re-export stubs.
+
### Modified files
| File | Change |
@@ -1037,5 +1015,6 @@ packages/utils/src/time/
| `packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts` | Same |
| `packages/utils/src/time/format.ts` | Add `TimeTranslate`, `TimeFormatOptions`, optional `translate` param |
| `packages/html/src/ui/media-button-element.ts` | Add `I18nController`; apply `t()` in `update()` |
-| `packages/html/src/define/video/skin.ts` | Replace hardcoded tooltip strings with `` children |
+| `packages/html/src/ui/tooltip/tooltip-element.ts` | Sync tooltip text from trigger `getResolvedLabel()` |
+| `packages/core/src/core/i18n/browser-translation.ts` | Browser Translation API fallback + `shouldAttemptBrowserTranslation` |
| `packages/react/src/i18n/index.ts` | Export `{ I18nProvider, useTranslator, useLocale }` from `createI18n()` |
diff --git a/packages/core/package.json b/packages/core/package.json
index a22eaa05..c58db596 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -15,7 +15,9 @@
"player",
"videojs"
],
- "sideEffects": false,
+ "sideEffects": [
+ "**/i18n/registry.js"
+ ],
"exports": {
".": {
"types": "./dist/dev/index.d.ts",
@@ -31,6 +33,16 @@
"types": "./dist/dev/dom/media/*/index.d.ts",
"development": "./dist/dev/dom/media/*/index.js",
"default": "./dist/default/dom/media/*/index.js"
+ },
+ "./i18n": {
+ "types": "./dist/dev/i18n.d.ts",
+ "development": "./dist/dev/i18n.js",
+ "default": "./dist/default/i18n.js"
+ },
+ "./i18n/locales/*": {
+ "types": "./dist/dev/i18n/locales/*.d.ts",
+ "development": "./dist/dev/i18n/locales/*.js",
+ "default": "./dist/default/i18n/locales/*.js"
}
},
"main": "dist/default/index.js",
diff --git a/packages/core/src/core/i18n/browser-translation.ts b/packages/core/src/core/i18n/browser-translation.ts
new file mode 100644
index 00000000..06011888
--- /dev/null
+++ b/packages/core/src/core/i18n/browser-translation.ts
@@ -0,0 +1,168 @@
+import en from './locales/en';
+import { getI18nTranslations, hasRegisteredI18n, localeLookupChain } from './registry';
+import type { Locale, Translations } from './types';
+
+type BrowserTranslatorAvailability = 'available' | 'downloadable' | 'downloading' | 'unavailable';
+
+interface BrowserTranslatorInstance {
+ translate(text: string): Promise;
+}
+
+interface BrowserTranslatorMonitor {
+ addEventListener(type: 'downloadprogress', listener: (event: { loaded: number }) => void): void;
+}
+
+interface BrowserTranslatorConstructor {
+ availability(options: { sourceLanguage: string; targetLanguage: string }): Promise;
+ create(options: {
+ sourceLanguage: string;
+ targetLanguage: string;
+ monitor?: (monitor: BrowserTranslatorMonitor) => void;
+ }): Promise;
+}
+
+export interface GetBrowserTranslationsOptions {
+ /**
+ * When true, call `Translator.create()` for `downloadable` / `downloading` (may download the
+ * on-device model). Defaults to false — production providers only use pre-installed models.
+ */
+ downloadIfNeeded?: boolean;
+ /** Invoked when a model download starts and when `Translator.create()` resolves. */
+ onModelDownload?: {
+ start?: (targetLanguage: string) => void;
+ finish?: (targetLanguage: string) => void;
+ };
+}
+
+const NAMED_PLACEHOLDER = /\{([^{}]+)\}/g;
+const INDEX_PLACEHOLDER = /\{\s*(\d+)\s*\}/g;
+
+/**
+ * Replaces `{seconds}` with `{0}`, `{1}`, … so the Browser Translation API sees one full
+ * sentence (grammar/word order preserved) while opaque numeric slots are left alone.
+ */
+function maskNamedPlaceholders(source: string): { masked: string; slots: readonly string[] } {
+ const slots: string[] = [];
+ const masked = source.replace(NAMED_PLACEHOLDER, (_, name: string) => {
+ slots.push(name);
+ return `{${slots.length - 1}}`;
+ });
+ return { masked, slots };
+}
+
+function restoreNamedPlaceholders(translated: string, slots: readonly string[]): string {
+ return translated.replace(INDEX_PLACEHOLDER, (match, index: string) => {
+ const name = slots[Number(index)];
+ return name !== undefined ? `{${name}}` : match;
+ });
+}
+
+async function translateProtectingPlaceholders(translator: BrowserTranslatorInstance, value: string): Promise {
+ const { masked, slots } = maskNamedPlaceholders(value);
+ if (slots.length === 0) {
+ return translator.translate(value);
+ }
+ const translated = await translator.translate(masked);
+ return restoreNamedPlaceholders(translated, slots);
+}
+
+const cache = new Map>();
+
+function isEnglishLocaleTag(tag: string): boolean {
+ return tag === 'en' || tag.startsWith('en-');
+}
+
+function getBrowserTranslator(): BrowserTranslatorConstructor | undefined {
+ if (!('Translator' in globalThis)) return undefined;
+ return (globalThis as typeof globalThis & { Translator: BrowserTranslatorConstructor }).Translator;
+}
+
+/** First non-English tag in the lookup chain used as the browser translation target. */
+export function resolveBrowserTranslationTarget(locale: string): string | undefined {
+ for (const tag of localeLookupChain(locale)) {
+ if (!isEnglishLocaleTag(tag)) return tag;
+ }
+ return undefined;
+}
+
+/** Whether to invoke the Browser Translation API for this locale after lazy built-in loading. */
+export function shouldAttemptBrowserTranslation(locale: Locale, loadedLazyTags: readonly string[]): boolean {
+ const target = resolveBrowserTranslationTarget(locale);
+ if (!target) return false;
+ if (loadedLazyTags.some((tag) => !isEnglishLocaleTag(tag))) return false;
+
+ return !localeLookupChain(locale).some((tag) => !isEnglishLocaleTag(tag) && hasRegisteredI18n(tag));
+}
+
+/**
+ * Translates English registry values via the on-device Browser Translation API when a pre-installed
+ * model is available. Results are cached per target language tag.
+ */
+export async function getBrowserTranslations(
+ locale: string,
+ options?: GetBrowserTranslationsOptions
+): Promise> {
+ const target = resolveBrowserTranslationTarget(locale);
+ if (!target) return {};
+
+ const cached = cache.get(target);
+ if (cached) return cached;
+
+ const Translator = getBrowserTranslator();
+ if (!Translator) return {};
+
+ const downloadIfNeeded = options?.downloadIfNeeded ?? false;
+
+ const availability = await Translator.availability({
+ sourceLanguage: 'en',
+ targetLanguage: target,
+ });
+ if (availability === 'unavailable') return {};
+ if (!downloadIfNeeded && availability !== 'available') return {};
+
+ const needsDownload = downloadIfNeeded && (availability === 'downloadable' || availability === 'downloading');
+ let downloadStarted = false;
+ const notifyDownloadStart = (): void => {
+ if (!needsDownload || downloadStarted) return;
+ downloadStarted = true;
+ options?.onModelDownload?.start?.(target);
+ };
+
+ notifyDownloadStart();
+
+ const english = getI18nTranslations('en');
+ const keys = Object.keys(en) as (keyof Translations)[];
+ const translator = await Translator.create({
+ sourceLanguage: 'en',
+ targetLanguage: target,
+ ...(downloadIfNeeded
+ ? {
+ monitor(monitor: BrowserTranslatorMonitor) {
+ monitor.addEventListener('downloadprogress', notifyDownloadStart);
+ },
+ }
+ : {}),
+ });
+
+ if (downloadStarted) {
+ options?.onModelDownload?.finish?.(target);
+ }
+
+ const entries = await Promise.all(
+ keys.map(async (key) => {
+ const value = english[key] ?? en[key];
+ if (!value) return [key, ''] as const;
+ const translated = await translateProtectingPlaceholders(translator, value);
+ return [key, translated] as const;
+ })
+ );
+
+ const result = Object.fromEntries(entries) as Partial;
+ cache.set(target, result);
+ return result;
+}
+
+/** Clears the browser translation cache (test isolation). */
+export function resetBrowserTranslationCacheForTesting(): void {
+ cache.clear();
+}
diff --git a/packages/core/src/core/i18n/index.ts b/packages/core/src/core/i18n/index.ts
new file mode 100644
index 00000000..6b535e15
--- /dev/null
+++ b/packages/core/src/core/i18n/index.ts
@@ -0,0 +1,19 @@
+export type { GetBrowserTranslationsOptions } from './browser-translation';
+export {
+ getBrowserTranslations,
+ resetBrowserTranslationCacheForTesting,
+ resolveBrowserTranslationTarget,
+ shouldAttemptBrowserTranslation,
+} from './browser-translation';
+export { default as translations } from './locales/en';
+export {
+ getI18nTranslations,
+ hasRegisteredI18n,
+ localeLookupChain,
+ onI18nRegistryChange,
+ registerI18n,
+ resetI18nRegistryForTesting,
+} from './registry';
+export { resolveTranslationPhrase } from './resolve-translation-phrase';
+export { createTranslator } from './translator';
+export type * from './types';
diff --git a/packages/core/src/core/i18n/locales/en.ts b/packages/core/src/core/i18n/locales/en.ts
new file mode 100644
index 00000000..fd48078e
--- /dev/null
+++ b/packages/core/src/core/i18n/locales/en.ts
@@ -0,0 +1,55 @@
+import type { Translations } from '../types';
+
+/** Default English layer — registered when `@videojs/core/i18n/registry` (or this package entry) is loaded. */
+export default {
+ play: 'Play',
+ pause: 'Pause',
+ replay: 'Replay',
+ mute: 'Mute',
+ unmute: 'Unmute',
+ seekForward: 'Seek forward {seconds} seconds',
+ seekBackward: 'Seek backward {seconds} seconds',
+ enterFullscreen: 'Enter fullscreen',
+ exitFullscreen: 'Exit fullscreen',
+ enableCaptions: 'Enable captions',
+ disableCaptions: 'Disable captions',
+ enterPictureInPicture: 'Enter picture-in-picture',
+ exitPictureInPicture: 'Exit picture-in-picture',
+ playingLive: 'Playing live',
+ seekToLiveEdge: 'Seek to live edge',
+ liveBadge: 'Live',
+ startCasting: 'Start casting',
+ stopCasting: 'Stop casting',
+ connectingCast: 'Connecting',
+ seek: 'Seek',
+ volume: 'Volume',
+ timeCurrent: 'Current time',
+ timeDuration: 'Duration',
+ timeRemaining: 'Remaining',
+ timeRemainingPhrase: '{duration} remaining',
+ playbackRateAria: 'Playback rate {rate}',
+ timeSliderValueTextRange: '{current} of {duration}',
+ volumeSliderValueTextMuted: '{percent}, muted',
+ indicatorMuted: 'Muted',
+ indicatorVolume: 'Volume',
+ indicatorVolumeWithValue: 'Volume {value}',
+ indicatorCaptionsOn: 'Captions on',
+ indicatorCaptionsOff: 'Captions off',
+ indicatorPaused: 'Paused',
+ indicatorPlaying: 'Playing',
+ indicatorFullscreen: 'Fullscreen',
+ indicatorExitFullscreen: 'Exit fullscreen',
+ indicatorPictureInPicture: 'Picture in picture',
+ indicatorExitPictureInPicture: 'Exit picture in picture',
+ mediaErrorAborted: 'You aborted the media playback',
+ mediaErrorNetwork: 'A network error caused the media download to fail.',
+ mediaErrorDecode:
+ 'A media error caused playback to be aborted. The media could be corrupt or your browser does not support this format.',
+ mediaErrorSrcNotSupported:
+ 'An unsupported error occurred. The server or network failed, or your browser does not support this format.',
+ mediaErrorEncrypted: 'The media is encrypted and there are no keys to decrypt it.',
+ mediaErrorCustom: '',
+ errorDialogTitle: 'Something went wrong.',
+ errorDialogDismiss: 'OK',
+ mediaErrorFallback: 'An error occurred. Please try again.',
+} as const satisfies Translations;
diff --git a/packages/core/src/core/i18n/registry.ts b/packages/core/src/core/i18n/registry.ts
new file mode 100644
index 00000000..eb772780
--- /dev/null
+++ b/packages/core/src/core/i18n/registry.ts
@@ -0,0 +1,107 @@
+import en from './locales/en';
+import type { Translations } from './types';
+
+const registry = new Map>();
+const subscribers = new Set<() => void>();
+
+function notify(): void {
+ for (const cb of subscribers) {
+ cb();
+ }
+}
+
+function normalizeLocaleTag(tag: string): string {
+ return tag.trim().replaceAll('_', '-').toLowerCase();
+}
+
+/** Strip unicode locale extension sequences (`-u-…`) before any private-use `-x-` block. */
+function stripUnicodeExtensions(tag: string): string {
+ const xIdx = tag.indexOf('-x-');
+ const beforePrivateUse = xIdx === -1 ? tag : tag.slice(0, xIdx);
+ const uIdx = beforePrivateUse.indexOf('-u-');
+ if (uIdx === -1) {
+ return tag;
+ }
+ return tag.slice(0, uIdx) + (xIdx === -1 ? '' : tag.slice(xIdx));
+}
+
+/** Registry map key: normalized tag with unicode extensions removed (same base as {@link localeLookupChain}). */
+function canonicalLocaleRegistryKey(locale: string): string {
+ return stripUnicodeExtensions(normalizeLocaleTag(locale));
+}
+
+/**
+ * Most-specific-first BCP 47 lookup tags (normalized). Always ends with `en` when missing from the truncated chain.
+ *
+ * @example `es-419-u-nu-latn` → `['es-419', 'es', 'en']`
+ */
+export function localeLookupChain(locale: string): string[] {
+ const base = canonicalLocaleRegistryKey(locale);
+ if (!base) {
+ return ['en'];
+ }
+
+ const segments = base.split('-').filter(Boolean);
+ const chain: string[] = [];
+
+ for (let len = segments.length; len >= 1; len--) {
+ chain.push(segments.slice(0, len).join('-'));
+ }
+
+ const out: string[] = [];
+ const seen = new Set();
+ for (const tag of chain) {
+ if (!seen.has(tag)) {
+ seen.add(tag);
+ out.push(tag);
+ }
+ }
+ if (!seen.has('en')) {
+ out.push('en');
+ }
+
+ return out;
+}
+
+function mergeLookupChain(chain: string[]): Translations {
+ const merged: Partial = {};
+ for (let i = chain.length - 1; i >= 0; i--) {
+ const tag = chain[i]!;
+ const layer = registry.get(tag);
+ if (layer) {
+ Object.assign(merged, layer);
+ }
+ }
+ return merged as Translations;
+}
+
+export function registerI18n(locale: string, translations: Partial): void {
+ const tag = canonicalLocaleRegistryKey(locale);
+ const existing = registry.get(tag) ?? {};
+ registry.set(tag, { ...existing, ...translations });
+ notify();
+}
+
+export function getI18nTranslations(locale: string): Translations {
+ return mergeLookupChain(localeLookupChain(locale));
+}
+
+export function onI18nRegistryChange(callback: () => void): () => void {
+ subscribers.add(callback);
+ return () => {
+ subscribers.delete(callback);
+ };
+}
+
+export function hasRegisteredI18n(locale: string): boolean {
+ return registry.has(canonicalLocaleRegistryKey(locale));
+}
+
+/** Restores the registry to built-in English only (test isolation). */
+export function resetI18nRegistryForTesting(): void {
+ registry.clear();
+ subscribers.clear();
+ registry.set('en', { ...en });
+}
+
+registry.set('en', { ...en });
diff --git a/packages/core/src/core/i18n/resolve-translation-phrase.ts b/packages/core/src/core/i18n/resolve-translation-phrase.ts
new file mode 100644
index 00000000..b7b1949d
--- /dev/null
+++ b/packages/core/src/core/i18n/resolve-translation-phrase.ts
@@ -0,0 +1,11 @@
+import type { TranslationKeyOrString, Translator } from './types';
+
+/** Resolves a {@link TranslationKeyOrString} with optional template params via a translator. */
+export function resolveTranslationPhrase(
+ translator: Translator,
+ phrase: TranslationKeyOrString,
+ params?: Record
+): string {
+ const translate = translator as (key: string, params?: unknown) => string;
+ return params !== undefined ? translate(phrase, params) : translate(phrase);
+}
diff --git a/packages/core/src/core/i18n/tests/browser-translation.test.ts b/packages/core/src/core/i18n/tests/browser-translation.test.ts
new file mode 100644
index 00000000..1eca0635
--- /dev/null
+++ b/packages/core/src/core/i18n/tests/browser-translation.test.ts
@@ -0,0 +1,196 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ getBrowserTranslations,
+ resetBrowserTranslationCacheForTesting,
+ resolveBrowserTranslationTarget,
+ shouldAttemptBrowserTranslation,
+} from '../browser-translation';
+import { registerI18n, resetI18nRegistryForTesting } from '../registry';
+
+type MockAvailability = 'available' | 'downloadable' | 'unavailable';
+
+function installMockTranslator(
+ options: { availability?: MockAvailability; translate?: (text: string) => string | Promise } = {}
+) {
+ const availability = options.availability ?? 'available';
+ const translate = options.translate ?? ((text: string) => `translated:${text}`);
+
+ const Translator = {
+ availability: vi.fn(async () => availability),
+ create: vi.fn(async () => ({
+ translate: vi.fn(async (text: string) => translate(text)),
+ })),
+ };
+
+ Object.defineProperty(globalThis, 'Translator', {
+ configurable: true,
+ writable: true,
+ value: Translator,
+ });
+
+ return Translator;
+}
+
+function removeMockTranslator(): void {
+ Reflect.deleteProperty(globalThis, 'Translator');
+}
+
+describe('resolveBrowserTranslationTarget', () => {
+ it('returns the first non-en tag in the lookup chain', () => {
+ expect(resolveBrowserTranslationTarget('fr-CA')).toBe('fr-ca');
+ expect(resolveBrowserTranslationTarget('en')).toBeUndefined();
+ expect(resolveBrowserTranslationTarget('en-US')).toBeUndefined();
+ expect(resolveBrowserTranslationTarget('en-GB')).toBeUndefined();
+ });
+});
+
+describe('shouldAttemptBrowserTranslation', () => {
+ afterEach(() => {
+ resetI18nRegistryForTesting();
+ });
+
+ it('skips English', () => {
+ expect(shouldAttemptBrowserTranslation('en', [])).toBe(false);
+ expect(shouldAttemptBrowserTranslation('en-US', [])).toBe(false);
+ });
+
+ it('skips when a non-English lazy built-in tag loaded', () => {
+ expect(shouldAttemptBrowserTranslation('xx', ['xx'])).toBe(false);
+ expect(shouldAttemptBrowserTranslation('es', ['en', 'es'])).toBe(false);
+ });
+
+ it('attempts when only English lazy tags loaded', () => {
+ expect(shouldAttemptBrowserTranslation('xx', ['en'])).toBe(true);
+ });
+
+ it('skips when a non-en tag in the chain is registered', () => {
+ registerI18n('es', { play: 'Ir' });
+ expect(shouldAttemptBrowserTranslation('es-MX', [])).toBe(false);
+ });
+
+ it('attempts when locale has no pack', () => {
+ expect(shouldAttemptBrowserTranslation('xx', [])).toBe(true);
+ });
+});
+
+describe('getBrowserTranslations', () => {
+ afterEach(() => {
+ resetI18nRegistryForTesting();
+ resetBrowserTranslationCacheForTesting();
+ removeMockTranslator();
+ });
+
+ it('returns empty when Translator API is missing', async () => {
+ removeMockTranslator();
+ await expect(getBrowserTranslations('fr')).resolves.toEqual({});
+ });
+
+ it('returns empty when availability is not available', async () => {
+ const translator = installMockTranslator({ availability: 'downloadable' });
+ await expect(getBrowserTranslations('fr')).resolves.toEqual({});
+ expect(translator.create).not.toHaveBeenCalled();
+ });
+
+ it('translates en values and maps them back to keys', async () => {
+ installMockTranslator({
+ translate: (text) => (text === 'Play' ? 'Jouer' : `translated:${text}`),
+ });
+
+ const result = await getBrowserTranslations('fr');
+ expect(result.play).toBe('Jouer');
+ expect(result.pause).toBe('translated:Pause');
+ });
+
+ it('preserves {param} placeholders in translated strings', async () => {
+ installMockTranslator({
+ translate: (text) => `FR:${text}`,
+ });
+
+ const result = await getBrowserTranslations('fr');
+ expect(result.seekForward).toBe('FR:Seek forward {seconds} seconds');
+ });
+
+ it('masks named placeholders as numeric slots for whole-string translation', async () => {
+ const translatedInputs: string[] = [];
+ installMockTranslator({
+ translate: (text) => {
+ translatedInputs.push(text);
+ if (text === 'Seek backward {0} seconds') {
+ return 'Mencari mundur {0} detik';
+ }
+ if (text === 'Seek forward {0} seconds') {
+ return 'Mencari maju {0} detik';
+ }
+ return text;
+ },
+ });
+
+ const result = await getBrowserTranslations('fr');
+ expect(translatedInputs).toContain('Seek backward {0} seconds');
+ expect(translatedInputs.some((text) => text.includes('{seconds}'))).toBe(false);
+ expect(result.seekBackward).toBe('Mencari mundur {seconds} detik');
+ expect(result.seekForward).toBe('Mencari maju {seconds} detik');
+ });
+
+ it('restores slots when the translator adds spaces inside braces', async () => {
+ installMockTranslator({
+ translate: (text) => (text === 'Playback rate {0}' ? 'Kecepatan pemutaran { 0 }' : text),
+ });
+
+ const result = await getBrowserTranslations('fr');
+ expect(result.playbackRateAria).toBe('Kecepatan pemutaran {rate}');
+ });
+
+ it('interpolates seek seconds after browser translation', async () => {
+ installMockTranslator({
+ translate: (text) => (text === 'Seek forward {0} seconds' ? 'Mencari maju {0} detik' : text),
+ });
+
+ const { createTranslator } = await import('../translator');
+ const result = await getBrowserTranslations('fr');
+ const t = createTranslator({ ...result, play: 'Play' } as import('../types').Translations, 'fr');
+
+ expect(t('seekForward', { seconds: 10 })).toBe('Mencari maju 10 detik');
+ });
+
+ it('caches results per target language', async () => {
+ const translator = installMockTranslator();
+ await getBrowserTranslations('fr');
+ await getBrowserTranslations('fr');
+
+ expect(translator.create).toHaveBeenCalledTimes(1);
+ });
+
+ it('downloads and translates when downloadIfNeeded is true', async () => {
+ const onStart = vi.fn();
+ const onFinish = vi.fn();
+ const translator = installMockTranslator({
+ availability: 'downloadable',
+ translate: (text) => (text === 'Play' ? 'Main' : text),
+ });
+
+ const result = await getBrowserTranslations('id', {
+ downloadIfNeeded: true,
+ onModelDownload: { start: onStart, finish: onFinish },
+ });
+ expect(translator.create).toHaveBeenCalledTimes(1);
+ expect(result.play).toBe('Main');
+ expect(onStart).toHaveBeenCalledWith('id');
+ expect(onFinish).toHaveBeenCalledWith('id');
+ });
+
+ it('does not invoke download callbacks when the model is already available', async () => {
+ const onStart = vi.fn();
+ const onFinish = vi.fn();
+ installMockTranslator();
+
+ await getBrowserTranslations('fr', {
+ downloadIfNeeded: true,
+ onModelDownload: { start: onStart, finish: onFinish },
+ });
+
+ expect(onStart).not.toHaveBeenCalled();
+ expect(onFinish).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/core/src/core/i18n/tests/registry.test.ts b/packages/core/src/core/i18n/tests/registry.test.ts
new file mode 100644
index 00000000..b88fdf87
--- /dev/null
+++ b/packages/core/src/core/i18n/tests/registry.test.ts
@@ -0,0 +1,93 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import en from '../locales/en';
+import {
+ getI18nTranslations,
+ hasRegisteredI18n,
+ localeLookupChain,
+ onI18nRegistryChange,
+ registerI18n,
+ resetI18nRegistryForTesting,
+} from '../registry';
+
+describe('i18n registry', () => {
+ beforeEach(() => {
+ resetI18nRegistryForTesting();
+ });
+
+ it('merges multiple registerI18n calls for one locale', () => {
+ registerI18n('es', { play: 'Ir' });
+ registerI18n('es', { pause: 'Pausa' });
+ const es = getI18nTranslations('es');
+ expect(es.play).toBe('Ir');
+ expect(es.pause).toBe('Pausa');
+ });
+
+ it('inherits from English for keys missing in the locale', () => {
+ registerI18n('es', { play: 'Ir' });
+ const es = getI18nTranslations('es');
+ expect(es.mute).toBe(en.mute);
+ });
+
+ it('resolves es-419-u-nu-latn to the es layer before English', () => {
+ registerI18n('es', { play: 'ES-generic' });
+ const merged = getI18nTranslations('es-419-u-nu-latn');
+ expect(merged.play).toBe('ES-generic');
+ });
+
+ it('registers unicode-extension locales under the same key as localeLookupChain', () => {
+ registerI18n('es-u-nu-latn', { play: 'Latin-numerals ES' });
+ expect(hasRegisteredI18n('es-u-nu-latn')).toBe(true);
+ expect(getI18nTranslations('es-u-nu-latn').play).toBe('Latin-numerals ES');
+ });
+
+ it('does not strip -u- subtags inside a private-use extension', () => {
+ registerI18n('en-x-u-k0', { play: 'Private U' });
+ registerI18n('en-x-u-k1', { play: 'Private U2' });
+ expect(getI18nTranslations('en-x-u-k0').play).toBe('Private U');
+ expect(getI18nTranslations('en-x-u-k1').play).toBe('Private U2');
+ });
+
+ it('walks zh-Hant-HK → zh-Hant → zh → en', () => {
+ expect(localeLookupChain('zh-Hant-HK')).toEqual(['zh-hant-hk', 'zh-hant', 'zh', 'en']);
+ registerI18n('zh', { play: 'ZH' });
+ registerI18n('zh-Hant', { pause: 'Hant' });
+ registerI18n('zh-Hant-HK', { replay: 'HK' });
+ const merged = getI18nTranslations('zh-Hant-HK');
+ expect(merged.replay).toBe('HK');
+ expect(merged.pause).toBe('Hant');
+ expect(merged.play).toBe('ZH');
+ expect(merged.mute).toBe(en.mute);
+ });
+
+ it('truncates en-GB-scotland toward en-GB then en', () => {
+ expect(localeLookupChain('en-GB-scotland')).toEqual(['en-gb-scotland', 'en-gb', 'en']);
+ registerI18n('en-GB', { play: 'GB' });
+ const merged = getI18nTranslations('en-GB-scotland');
+ expect(merged.play).toBe('GB');
+ });
+
+ it('does not use a sibling locale tag when only the parent matches (es-AR vs es-MX)', () => {
+ registerI18n('es-MX', { play: 'MX' });
+ registerI18n('es', { play: 'ES' });
+ const merged = getI18nTranslations('es-AR');
+ expect(merged.play).toBe('ES');
+ });
+
+ it('reports hasRegisteredI18n with normalized tags', () => {
+ expect(hasRegisteredI18n('en')).toBe(true);
+ expect(hasRegisteredI18n('fr')).toBe(false);
+ registerI18n('fr', { play: 'Lire' });
+ expect(hasRegisteredI18n('FR')).toBe(true);
+ });
+
+ it('onI18nRegistryChange unsubscribe stops notifications', () => {
+ const spy = vi.fn();
+ const off = onI18nRegistryChange(spy);
+ registerI18n('de', { play: 'Los' });
+ expect(spy).toHaveBeenCalledOnce();
+ off();
+ registerI18n('de', { pause: 'Pause' });
+ expect(spy).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/core/src/core/i18n/tests/translator.test.ts b/packages/core/src/core/i18n/tests/translator.test.ts
new file mode 100644
index 00000000..272d08a4
--- /dev/null
+++ b/packages/core/src/core/i18n/tests/translator.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+
+import { createTranslator } from '../translator';
+
+describe('createTranslator', () => {
+ it('resolves a simple key', () => {
+ const t = createTranslator({ play: 'Start' }, 'en');
+ expect(t('play')).toBe('Start');
+ });
+
+ it('interpolates {param} placeholders', () => {
+ const t = createTranslator({ seekForward: 'Jump {seconds} s' }, 'en');
+ expect(t('seekForward', { seconds: 10 })).toBe('Jump 10 s');
+ });
+
+ it('falls back to the key when no translation is defined', () => {
+ const t = createTranslator({}, 'en');
+ expect(t('play')).toBe('play');
+ });
+
+ it('keeps tokens that are not supplied as params', () => {
+ const t = createTranslator({ play: 'Hi {name}' }, 'en');
+ expect(t('play')).toBe('Hi {name}');
+ });
+
+ it('coerces numeric params to strings', () => {
+ const t = createTranslator({ playbackRateAria: 'Speed {rate}' }, 'en');
+ expect(t('playbackRateAria', { rate: 1.25 })).toBe('Speed 1.25');
+ });
+});
diff --git a/packages/core/src/core/i18n/translator.ts b/packages/core/src/core/i18n/translator.ts
new file mode 100644
index 00000000..d86a121a
--- /dev/null
+++ b/packages/core/src/core/i18n/translator.ts
@@ -0,0 +1,28 @@
+import type { Locale, TranslationParams, Translations, Translator } from './types';
+
+const PLACEHOLDER = /\{([^{}]+)\}/g;
+
+function interpolate(template: string, params?: Record): string {
+ if (!params) return template;
+ return template.replace(PLACEHOLDER, (match, name: string) => {
+ if (Object.hasOwn(params, name)) {
+ return String(params[name as keyof typeof params]);
+ }
+ return match;
+ });
+}
+
+/** Builds a typed translator from a resolved translation map (typically from {@link getI18nTranslations}). */
+export function createTranslator(translations: Translations, locale: Locale): Translator {
+ void locale;
+
+ const translate = (key: keyof TranslationParams, params?: unknown): string => {
+ const raw = translations[key];
+ if (raw === undefined) {
+ return String(key);
+ }
+ return interpolate(raw, params as Record | undefined);
+ };
+
+ return translate as Translator;
+}
diff --git a/packages/core/src/core/i18n/types.ts b/packages/core/src/core/i18n/types.ts
new file mode 100644
index 00000000..d5cfbf85
--- /dev/null
+++ b/packages/core/src/core/i18n/types.ts
@@ -0,0 +1,91 @@
+/** Matches strings that include the literal substring `needle` (for example a `{param}` token). */
+export type Contains = `${string}${Needle}${string}`;
+
+/** Shipped built-in locale tags are expanded in a follow-up PR with locale packs. */
+export type BuiltInLocale = 'en';
+
+/** BCP 47 language tag; built-ins are narrowed for autocomplete. */
+export type Locale = BuiltInLocale | (string & {});
+
+/** Per-key argument contract: `never` means the translator only accepts the key. */
+export type TranslationParams = {
+ play: never;
+ pause: never;
+ replay: never;
+ mute: never;
+ unmute: never;
+ seekForward: { seconds: number | string };
+ seekBackward: { seconds: number | string };
+ enterFullscreen: never;
+ exitFullscreen: never;
+ enableCaptions: never;
+ disableCaptions: never;
+ enterPictureInPicture: never;
+ exitPictureInPicture: never;
+ playingLive: never;
+ seekToLiveEdge: never;
+ liveBadge: never;
+ startCasting: never;
+ stopCasting: never;
+ connectingCast: never;
+ seek: never;
+ volume: never;
+ timeCurrent: never;
+ timeDuration: never;
+ timeRemaining: never;
+ timeRemainingPhrase: { duration: string };
+ playbackRateAria: { rate: number | string };
+ timeSliderValueTextRange: { current: string; duration: string };
+ volumeSliderValueTextMuted: { percent: number | string };
+ indicatorMuted: never;
+ indicatorVolume: never;
+ indicatorVolumeWithValue: { value: string };
+ indicatorCaptionsOn: never;
+ indicatorCaptionsOff: never;
+ indicatorPaused: never;
+ indicatorPlaying: never;
+ indicatorFullscreen: never;
+ indicatorExitFullscreen: never;
+ indicatorPictureInPicture: never;
+ indicatorExitPictureInPicture: never;
+ mediaErrorAborted: never;
+ mediaErrorNetwork: never;
+ mediaErrorDecode: never;
+ mediaErrorSrcNotSupported: never;
+ mediaErrorEncrypted: never;
+ mediaErrorCustom: never;
+ errorDialogTitle: never;
+ errorDialogDismiss: never;
+ mediaErrorFallback: never;
+};
+
+/**
+ * Either a known translation id from {@link TranslationParams}, or any other string the platform may
+ * use as copy (custom overlay key, literal text, etc.).
+ */
+export type TranslationKeyOrString = keyof TranslationParams | (string & {});
+
+/** Placeholder shape for each key that accepts `t(key, params)`. Omitting a key here is a type error when defining strings. */
+type ParametricTranslations = {
+ seekForward: Contains<'{seconds}'>;
+ seekBackward: Contains<'{seconds}'>;
+ playbackRateAria: Contains<'{rate}'>;
+ timeSliderValueTextRange: Contains<'{current}'> & Contains<'{duration}'>;
+ timeRemainingPhrase: Contains<'{duration}'>;
+ volumeSliderValueTextMuted: Contains<'{percent}'>;
+ indicatorVolumeWithValue: Contains<'{value}'>;
+};
+
+/** Player copy keyed by camelCase tokens; all entries are optional overlay keys. */
+export type Translations = {
+ [K in keyof TranslationParams]?: TranslationParams[K] extends never
+ ? string
+ : K extends keyof ParametricTranslations
+ ? ParametricTranslations[K]
+ : never;
+};
+
+export type Translator = (
+ key: K,
+ ...args: TranslationParams[K] extends never ? [] : [params: TranslationParams[K]]
+) => string;
diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts
index 274d9597..6f2d8316 100644
--- a/packages/core/src/core/index.ts
+++ b/packages/core/src/core/index.ts
@@ -16,9 +16,11 @@ export * from './ui/controls/controls-core';
export * from './ui/controls/controls-data-attrs';
export * from './ui/error-dialog/error-dialog-core';
export * from './ui/error-dialog/error-dialog-data-attrs';
+export * from './ui/error-dialog/error-dialog-i18n';
export * from './ui/fullscreen-button/fullscreen-button-core';
export * from './ui/fullscreen-button/fullscreen-button-data-attrs';
export * from './ui/input-feedback/indicator-lifecycle';
+export * from './ui/input-feedback/labels';
export * from './ui/input-feedback/seek-indicator-core';
export * from './ui/input-feedback/seek-indicator-data-attrs';
export * from './ui/input-feedback/status';
@@ -52,6 +54,8 @@ export * from './ui/poster/poster-core';
export * from './ui/poster/poster-data-attrs';
export * from './ui/quality-radio-group/quality-radio-group-core';
export * from './ui/quality-radio-group/quality-radio-group-data-attrs';
+export * from './ui/resolve-control-attrs';
+export * from './ui/resolve-optional-control-label';
export * from './ui/seek-button/seek-button-core';
export * from './ui/seek-button/seek-button-data-attrs';
export * from './ui/slider/slider-core';
diff --git a/packages/core/src/core/ui/captions-button/captions-button-core.ts b/packages/core/src/core/ui/captions-button/captions-button-core.ts
index 7bb009b3..0ac42c14 100644
--- a/packages/core/src/core/ui/captions-button/captions-button-core.ts
+++ b/packages/core/src/core/ui/captions-button/captions-button-core.ts
@@ -1,15 +1,15 @@
import { createState } from '@videojs/store';
import { isCaptionOrSubtitleTrack } from '@videojs/utils/dom';
import { defaults } from '@videojs/utils/object';
-import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaTextTrackState } from '../../media/state';
-import type { ButtonState } from '../types';
+import { resolveOptionalControlLabel } from '../resolve-optional-control-label';
+import type { ButtonState, TranslationKeyOrString } from '../types';
export interface CaptionsButtonProps {
/** Custom label for the button. */
- label?: string | ((state: CaptionsButtonState) => string) | undefined;
+ label?: TranslationKeyOrString | ((state: CaptionsButtonState) => TranslationKeyOrString) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
/** When true with multiple tracks, pointer activation opens a menu instead of toggling. React sets this automatically inside `Menu.Trigger`. */
@@ -44,17 +44,11 @@ export class CaptionsButtonCore {
this.#props = defaults(props, CaptionsButtonCore.defaultProps);
}
- getLabel(state: CaptionsButtonState): string {
- const { label } = this.#props;
+ getLabel(state: CaptionsButtonState): TranslationKeyOrString {
+ const custom = resolveOptionalControlLabel(this.#props.label, state);
+ if (custom !== undefined) return custom;
- if (isFunction(label)) {
- const customLabel = label(state);
- if (customLabel) return customLabel;
- } else if (label) {
- return label;
- }
-
- return state.subtitlesShowing ? 'Disable captions' : 'Enable captions';
+ return state.subtitlesShowing ? 'disableCaptions' : 'enableCaptions';
}
getAttrs(state: CaptionsButtonState) {
diff --git a/packages/core/src/core/ui/captions-button/tests/captions-button-core.test.ts b/packages/core/src/core/ui/captions-button/tests/captions-button-core.test.ts
index 14a45189..1835c82d 100644
--- a/packages/core/src/core/ui/captions-button/tests/captions-button-core.test.ts
+++ b/packages/core/src/core/ui/captions-button/tests/captions-button-core.test.ts
@@ -69,12 +69,12 @@ describe('CaptionsButtonCore', () => {
describe('getLabel', () => {
it('returns Enable captions when captions are disabled', () => {
const core = new CaptionsButtonCore();
- expect(core.getLabel(createState({ subtitlesShowing: false }))).toBe('Enable captions');
+ expect(core.getLabel(createState({ subtitlesShowing: false }))).toBe('enableCaptions');
});
it('returns Disable captions when captions are enabled', () => {
const core = new CaptionsButtonCore();
- expect(core.getLabel(createState({ subtitlesShowing: true }))).toBe('Disable captions');
+ expect(core.getLabel(createState({ subtitlesShowing: true }))).toBe('disableCaptions');
});
it('returns custom string label', () => {
@@ -94,7 +94,7 @@ describe('CaptionsButtonCore', () => {
it('returns aria-label', () => {
const core = new CaptionsButtonCore();
const attrs = core.getAttrs(createState({ subtitlesShowing: false }));
- expect(attrs['aria-label']).toBe('Enable captions');
+ expect(attrs['aria-label']).toBe('enableCaptions');
});
it('sets aria-disabled when disabled', () => {
diff --git a/packages/core/src/core/ui/cast-button/cast-button-core.ts b/packages/core/src/core/ui/cast-button/cast-button-core.ts
index c694d5f9..8d91dc09 100644
--- a/packages/core/src/core/ui/cast-button/cast-button-core.ts
+++ b/packages/core/src/core/ui/cast-button/cast-button-core.ts
@@ -1,15 +1,15 @@
import { createState } from '@videojs/store';
import { defaults } from '@videojs/utils/object';
-import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaRemotePlaybackState, RemotePlaybackConnectionState } from '../../media/state';
import type { MediaFeatureAvailability } from '../../media/types';
-import type { ButtonState } from '../types';
+import { resolveOptionalControlLabel } from '../resolve-optional-control-label';
+import type { ButtonState, TranslationKeyOrString } from '../types';
export interface CastButtonProps {
/** Custom label for the button. */
- label?: string | ((state: CastButtonState) => string) | undefined;
+ label?: TranslationKeyOrString | ((state: CastButtonState) => TranslationKeyOrString) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
}
@@ -42,19 +42,13 @@ export class CastButtonCore {
this.#props = defaults(props, CastButtonCore.defaultProps);
}
- getLabel(state: CastButtonState): string {
- const { label } = this.#props;
+ getLabel(state: CastButtonState): TranslationKeyOrString {
+ const custom = resolveOptionalControlLabel(this.#props.label, state);
+ if (custom !== undefined) return custom;
- if (isFunction(label)) {
- const customLabel = label(state);
- if (customLabel) return customLabel;
- } else if (label) {
- return label;
- }
-
- if (state.castState === 'connected') return 'Stop casting';
- if (state.castState === 'connecting') return 'Connecting';
- return 'Start casting';
+ if (state.castState === 'connected') return 'stopCasting';
+ if (state.castState === 'connecting') return 'connectingCast';
+ return 'startCasting';
}
getAttrs(state: CastButtonState) {
diff --git a/packages/core/src/core/ui/cast-button/tests/cast-button-core.test.ts b/packages/core/src/core/ui/cast-button/tests/cast-button-core.test.ts
index fc0b9781..a308cadb 100644
--- a/packages/core/src/core/ui/cast-button/tests/cast-button-core.test.ts
+++ b/packages/core/src/core/ui/cast-button/tests/cast-button-core.test.ts
@@ -78,17 +78,17 @@ describe('CastButtonCore', () => {
describe('getLabel', () => {
it('returns Start casting when disconnected', () => {
const core = new CastButtonCore();
- expect(core.getLabel(createState({ castState: 'disconnected' }))).toBe('Start casting');
+ expect(core.getLabel(createState({ castState: 'disconnected' }))).toBe('startCasting');
});
it('returns Stop casting when connected', () => {
const core = new CastButtonCore();
- expect(core.getLabel(createState({ castState: 'connected' }))).toBe('Stop casting');
+ expect(core.getLabel(createState({ castState: 'connected' }))).toBe('stopCasting');
});
it('returns Connecting when connecting', () => {
const core = new CastButtonCore();
- expect(core.getLabel(createState({ castState: 'connecting' }))).toBe('Connecting');
+ expect(core.getLabel(createState({ castState: 'connecting' }))).toBe('connectingCast');
});
it('returns custom string label', () => {
@@ -108,7 +108,7 @@ describe('CastButtonCore', () => {
it('returns aria-label', () => {
const core = new CastButtonCore();
const attrs = core.getAttrs(createState());
- expect(attrs['aria-label']).toBe('Start casting');
+ expect(attrs['aria-label']).toBe('startCasting');
});
it('sets aria-disabled when disabled', () => {
diff --git a/packages/core/src/core/ui/error-dialog/error-dialog-i18n.ts b/packages/core/src/core/ui/error-dialog/error-dialog-i18n.ts
new file mode 100644
index 00000000..949e1d84
--- /dev/null
+++ b/packages/core/src/core/ui/error-dialog/error-dialog-i18n.ts
@@ -0,0 +1,78 @@
+import type { TranslationKeyOrString, TranslationParams } from '../../i18n/types';
+import { MediaError } from '../../media/media-error';
+
+export type MediaErrorTranslationKey = Extract<
+ keyof TranslationParams,
+ | 'mediaErrorAborted'
+ | 'mediaErrorNetwork'
+ | 'mediaErrorDecode'
+ | 'mediaErrorSrcNotSupported'
+ | 'mediaErrorEncrypted'
+ | 'mediaErrorCustom'
+>;
+
+const MEDIA_ERROR_CODE_TO_KEY: Record = {
+ [MediaError.MEDIA_ERR_ABORTED]: 'mediaErrorAborted',
+ [MediaError.MEDIA_ERR_NETWORK]: 'mediaErrorNetwork',
+ [MediaError.MEDIA_ERR_DECODE]: 'mediaErrorDecode',
+ [MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED]: 'mediaErrorSrcNotSupported',
+ [MediaError.MEDIA_ERR_ENCRYPTED]: 'mediaErrorEncrypted',
+ [MediaError.MEDIA_ERR_CUSTOM]: 'mediaErrorCustom',
+};
+
+const STANDARD_CODE_UA_MESSAGES: Partial> = {
+ [MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED]: ['Failed to open media'],
+};
+
+function isStandardMediaErrorCode(code: number): boolean {
+ return (
+ code === MediaError.MEDIA_ERR_ABORTED ||
+ code === MediaError.MEDIA_ERR_NETWORK ||
+ code === MediaError.MEDIA_ERR_DECODE ||
+ code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED ||
+ code === MediaError.MEDIA_ERR_ENCRYPTED
+ );
+}
+
+export function getMediaErrorTranslationKey(code: number): MediaErrorTranslationKey | undefined {
+ return MEDIA_ERROR_CODE_TO_KEY[code];
+}
+
+export function getErrorDialogTitleLabel(): TranslationKeyOrString {
+ return 'errorDialogTitle';
+}
+
+export function getErrorDialogDismissLabel(): TranslationKeyOrString {
+ return 'errorDialogDismiss';
+}
+
+/**
+ * Resolves dialog body copy: registry keys for known {@link MediaError} defaults, literal text for
+ * custom messages, otherwise the generic fallback key.
+ */
+export function resolveErrorDialogDescription(
+ error: (Pick & { context?: MediaError['context'] }) | null | undefined,
+ cachedMessage?: string | null
+): TranslationKeyOrString {
+ if (error) {
+ const key = getMediaErrorTranslationKey(error.code);
+ const message = error.message?.trim();
+ if (message) {
+ const defaultForCode = MediaError.defaultMessages[error.code];
+ if (key && defaultForCode && message === defaultForCode) {
+ return key;
+ }
+ const uaVariants = STANDARD_CODE_UA_MESSAGES[error.code];
+ if (key && isStandardMediaErrorCode(error.code) && !error.context && uaVariants?.includes(message)) {
+ return key;
+ }
+ return message;
+ }
+ if (key) return key;
+ }
+
+ const cached = cachedMessage?.trim();
+ if (cached) return cached;
+
+ return 'mediaErrorFallback';
+}
diff --git a/packages/core/src/core/ui/error-dialog/tests/error-dialog-i18n.test.ts b/packages/core/src/core/ui/error-dialog/tests/error-dialog-i18n.test.ts
new file mode 100644
index 00000000..73f0e170
--- /dev/null
+++ b/packages/core/src/core/ui/error-dialog/tests/error-dialog-i18n.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import { MediaError } from '../../../media/media-error';
+import {
+ getErrorDialogDismissLabel,
+ getErrorDialogTitleLabel,
+ getMediaErrorTranslationKey,
+ resolveErrorDialogDescription,
+} from '../error-dialog-i18n';
+
+describe('getMediaErrorTranslationKey', () => {
+ it('maps standard MediaError codes to registry keys', () => {
+ expect(getMediaErrorTranslationKey(MediaError.MEDIA_ERR_NETWORK)).toBe('mediaErrorNetwork');
+ expect(getMediaErrorTranslationKey(MediaError.MEDIA_ERR_ABORTED)).toBe('mediaErrorAborted');
+ });
+});
+
+describe('getErrorDialogTitleLabel', () => {
+ it('returns the error dialog title key', () => {
+ expect(getErrorDialogTitleLabel()).toBe('errorDialogTitle');
+ });
+});
+
+describe('getErrorDialogDismissLabel', () => {
+ it('returns the dismiss button key', () => {
+ expect(getErrorDialogDismissLabel()).toBe('errorDialogDismiss');
+ });
+});
+
+describe('resolveErrorDialogDescription', () => {
+ it('returns a registry key when the message matches the default for the code', () => {
+ const error = new MediaError(undefined, MediaError.MEDIA_ERR_NETWORK);
+ expect(resolveErrorDialogDescription(error, null)).toBe('mediaErrorNetwork');
+ });
+
+ it('returns custom message text when context is provided', () => {
+ const error = new MediaError('Custom failure', MediaError.MEDIA_ERR_NETWORK, true, 'hls');
+ expect(resolveErrorDialogDescription(error, null)).toBe('Custom failure');
+ });
+
+ it('returns custom message text on standard codes without context', () => {
+ const error = new MediaError('App network failure', MediaError.MEDIA_ERR_NETWORK);
+ expect(resolveErrorDialogDescription(error, null)).toBe('App network failure');
+ });
+
+ it('returns a registry key for browser-specific messages on standard codes', () => {
+ const error = new MediaError('Failed to open media', MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
+ expect(resolveErrorDialogDescription(error, null)).toBe('mediaErrorSrcNotSupported');
+ });
+
+ it('falls back to cached message then generic key', () => {
+ expect(resolveErrorDialogDescription(null, 'Cached')).toBe('Cached');
+ expect(resolveErrorDialogDescription(null, null)).toBe('mediaErrorFallback');
+ });
+});
diff --git a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts
index 216e9ec2..81772f93 100644
--- a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts
+++ b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts
@@ -1,14 +1,14 @@
import { createState } from '@videojs/store';
import { defaults } from '@videojs/utils/object';
-import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaFullscreenState } from '../../media/state';
-import type { ButtonState } from '../types';
+import { resolveOptionalControlLabel } from '../resolve-optional-control-label';
+import type { ButtonState, TranslationKeyOrString } from '../types';
export interface FullscreenButtonProps {
/** Custom label for the button. */
- label?: string | ((state: FullscreenButtonState) => string) | undefined;
+ label?: TranslationKeyOrString | ((state: FullscreenButtonState) => TranslationKeyOrString) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
}
@@ -41,17 +41,11 @@ export class FullscreenButtonCore {
this.#props = defaults(props, FullscreenButtonCore.defaultProps);
}
- getLabel(state: FullscreenButtonState): string {
- const { label } = this.#props;
+ getLabel(state: FullscreenButtonState): TranslationKeyOrString {
+ const custom = resolveOptionalControlLabel(this.#props.label, state);
+ if (custom !== undefined) return custom;
- if (isFunction(label)) {
- const customLabel = label(state);
- if (customLabel) return customLabel;
- } else if (label) {
- return label;
- }
-
- return state.fullscreen ? 'Exit fullscreen' : 'Enter fullscreen';
+ return state.fullscreen ? 'exitFullscreen' : 'enterFullscreen';
}
getAttrs(state: FullscreenButtonState) {
diff --git a/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts b/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts
index 8cc51c43..fe77b6a3 100644
--- a/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts
+++ b/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts
@@ -48,12 +48,12 @@ describe('FullscreenButtonCore', () => {
describe('getLabel', () => {
it('returns Enter fullscreen when not fullscreen', () => {
const core = new FullscreenButtonCore();
- expect(core.getLabel(createState({ fullscreen: false }))).toBe('Enter fullscreen');
+ expect(core.getLabel(createState({ fullscreen: false }))).toBe('enterFullscreen');
});
it('returns Exit fullscreen when fullscreen', () => {
const core = new FullscreenButtonCore();
- expect(core.getLabel(createState({ fullscreen: true }))).toBe('Exit fullscreen');
+ expect(core.getLabel(createState({ fullscreen: true }))).toBe('exitFullscreen');
});
it('returns custom string label', () => {
@@ -73,7 +73,7 @@ describe('FullscreenButtonCore', () => {
it('returns aria-label', () => {
const core = new FullscreenButtonCore();
const attrs = core.getAttrs(createState());
- expect(attrs['aria-label']).toBe('Enter fullscreen');
+ expect(attrs['aria-label']).toBe('enterFullscreen');
});
it('sets aria-disabled when disabled', () => {
diff --git a/packages/core/src/core/ui/input-feedback/labels.ts b/packages/core/src/core/ui/input-feedback/labels.ts
new file mode 100644
index 00000000..b1829bcc
--- /dev/null
+++ b/packages/core/src/core/ui/input-feedback/labels.ts
@@ -0,0 +1,19 @@
+import type { Translator } from '../../i18n/types';
+import type { InputIndicatorLabels } from './status';
+
+/** Maps i18n indicator keys to {@link InputIndicatorLabels} for status / volume feedback. */
+export function createInputIndicatorLabels(translator: Translator): InputIndicatorLabels {
+ return {
+ muted: translator('indicatorMuted'),
+ volume: translator('indicatorVolume'),
+ volumeWithValue: (value) => translator('indicatorVolumeWithValue', { value }),
+ captionsOn: translator('indicatorCaptionsOn'),
+ captionsOff: translator('indicatorCaptionsOff'),
+ paused: translator('indicatorPaused'),
+ playing: translator('indicatorPlaying'),
+ fullscreen: translator('indicatorFullscreen'),
+ exitFullscreen: translator('indicatorExitFullscreen'),
+ pictureInPicture: translator('indicatorPictureInPicture'),
+ exitPictureInPicture: translator('indicatorExitPictureInPicture'),
+ };
+}
diff --git a/packages/core/src/core/ui/input-feedback/status.ts b/packages/core/src/core/ui/input-feedback/status.ts
index aa6d346d..ae1f620a 100644
--- a/packages/core/src/core/ui/input-feedback/status.ts
+++ b/packages/core/src/core/ui/input-feedback/status.ts
@@ -56,6 +56,7 @@ export interface MediaSnapshot {
export interface InputIndicatorLabels {
muted: string;
volume: string;
+ volumeWithValue: (value: string) => string;
captionsOn: string;
captionsOff: string;
paused: string;
@@ -76,6 +77,7 @@ export interface StatusDetails {
export const DEFAULT_INPUT_INDICATOR_LABELS: InputIndicatorLabels = {
muted: 'Muted',
volume: 'Volume',
+ volumeWithValue: (value) => `Volume ${value}`,
captionsOn: 'Captions on',
captionsOff: 'Captions off',
paused: 'Paused',
@@ -154,7 +156,7 @@ export function deriveAnnouncerLabel(
if (!details) return null;
if (isVolumeIndicatorAction(event.action)) {
- return details.status === 'volume-off' ? labels.muted : `${labels.volume} ${details.value}`;
+ return details.status === 'volume-off' ? labels.muted : labels.volumeWithValue(details.value ?? '');
}
return details.label;
diff --git a/packages/core/src/core/ui/input-feedback/tests/labels.test.ts b/packages/core/src/core/ui/input-feedback/tests/labels.test.ts
new file mode 100644
index 00000000..f26d63d9
--- /dev/null
+++ b/packages/core/src/core/ui/input-feedback/tests/labels.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest';
+import { createTranslator, type Translations } from '../../../i18n';
+
+import { createInputIndicatorLabels } from '../labels';
+
+describe('createInputIndicatorLabels', () => {
+ it('maps indicator translation keys to input feedback labels', () => {
+ const labels = createInputIndicatorLabels(
+ createTranslator(
+ {
+ indicatorMuted: 'Muet',
+ indicatorVolume: 'Volume',
+ indicatorVolumeWithValue: 'Volume {value}',
+ indicatorCaptionsOn: 'Sous-titres activés',
+ indicatorCaptionsOff: 'Sous-titres désactivés',
+ indicatorPaused: 'En pause',
+ indicatorPlaying: 'Lecture en cours',
+ indicatorFullscreen: 'Plein écran',
+ indicatorExitFullscreen: 'Quitter le plein écran',
+ indicatorPictureInPicture: 'Image dans l’image',
+ indicatorExitPictureInPicture: 'Quitter l’image dans l’image',
+ } satisfies Translations,
+ 'fr'
+ )
+ );
+
+ expect(labels.captionsOn).toBe('Sous-titres activés');
+ expect(labels.captionsOff).toBe('Sous-titres désactivés');
+ expect(labels.paused).toBe('En pause');
+ expect(labels.volumeWithValue('80%')).toBe('Volume 80%');
+ });
+});
diff --git a/packages/core/src/core/ui/input-feedback/volume-indicator-core.ts b/packages/core/src/core/ui/input-feedback/volume-indicator-core.ts
index e43e1ffb..0709c70b 100644
--- a/packages/core/src/core/ui/input-feedback/volume-indicator-core.ts
+++ b/packages/core/src/core/ui/input-feedback/volume-indicator-core.ts
@@ -7,12 +7,15 @@ import {
deriveVolumeStatus,
type IndicatorVolumeLevel,
type InputActionEvent,
+ type InputIndicatorLabels,
isVolumeIndicatorAction,
type MediaSnapshot,
predictVolumeActionOutcome,
} from './status';
-export interface VolumeIndicatorProps extends IndicatorCoreProps {}
+export interface VolumeIndicatorProps extends IndicatorCoreProps {
+ labels?: Partial | undefined;
+}
export interface VolumeIndicatorState extends IndicatorLifecycleState {
level: IndicatorVolumeLevel | null;
@@ -66,7 +69,12 @@ export class VolumeIndicatorCore {
const current = this.state.current;
const prediction = predictVolumeActionOutcome(event, snapshot);
- const details = deriveVolumeStatus(event, snapshot, DEFAULT_INPUT_INDICATOR_LABELS, prediction);
+ const details = deriveVolumeStatus(
+ event,
+ snapshot,
+ { ...DEFAULT_INPUT_INDICATOR_LABELS, ...this.#props.labels },
+ prediction
+ );
const boundary = getVolumeBoundary(event, prediction.snapshotVolume, prediction.nextVolume);
const repeatedBoundary = boundary !== null && current[boundary] === true;
diff --git a/packages/core/src/core/ui/live-button/live-button-core.ts b/packages/core/src/core/ui/live-button/live-button-core.ts
index af1d47f3..1d3049c5 100644
--- a/packages/core/src/core/ui/live-button/live-button-core.ts
+++ b/packages/core/src/core/ui/live-button/live-button-core.ts
@@ -1,14 +1,14 @@
import { createState } from '@videojs/store';
import { defaults } from '@videojs/utils/object';
-import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaBufferState, MediaLiveState, MediaTimeState } from '../../media/state';
-import type { ButtonState } from '../types';
+import { resolveOptionalControlLabel } from '../resolve-optional-control-label';
+import type { ButtonState, TranslationKeyOrString } from '../types';
export interface LiveButtonProps {
/** Custom label for the button. */
- label?: string | ((state: LiveButtonState) => string) | undefined;
+ label?: TranslationKeyOrString | ((state: LiveButtonState) => TranslationKeyOrString) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
}
@@ -82,18 +82,12 @@ export class LiveButtonCore {
this.#props = defaults(props, LiveButtonCore.defaultProps);
}
- getLabel(state: LiveButtonState): string {
- const { label } = this.#props;
+ getLabel(state: LiveButtonState): TranslationKeyOrString {
+ const custom = resolveOptionalControlLabel(this.#props.label, state);
+ if (custom !== undefined) return custom;
- if (isFunction(label)) {
- const customLabel = label(state);
- if (customLabel) return customLabel;
- } else if (label) {
- return label;
- }
-
- if (state.liveEdge) return 'Playing live';
- return 'Seek to live edge';
+ if (state.liveEdge) return 'playingLive';
+ return 'seekToLiveEdge';
}
getAttrs(state: LiveButtonState) {
diff --git a/packages/core/src/core/ui/live-button/tests/live-button-core.test.ts b/packages/core/src/core/ui/live-button/tests/live-button-core.test.ts
index d69ea2ba..29d41f12 100644
--- a/packages/core/src/core/ui/live-button/tests/live-button-core.test.ts
+++ b/packages/core/src/core/ui/live-button/tests/live-button-core.test.ts
@@ -148,12 +148,12 @@ describe('LiveButtonCore', () => {
describe('getLabel', () => {
it('returns "Seek to live edge" when behind live', () => {
const core = new LiveButtonCore();
- expect(core.getLabel(createState({ live: true, liveEdge: false }))).toBe('Seek to live edge');
+ expect(core.getLabel(createState({ live: true, liveEdge: false }))).toBe('seekToLiveEdge');
});
it('returns "Playing live" when at live edge', () => {
const core = new LiveButtonCore();
- expect(core.getLabel(createState({ live: true, liveEdge: true }))).toBe('Playing live');
+ expect(core.getLabel(createState({ live: true, liveEdge: true }))).toBe('playingLive');
});
it('returns custom string label', () => {
diff --git a/packages/core/src/core/ui/mute-button/mute-button-core.ts b/packages/core/src/core/ui/mute-button/mute-button-core.ts
index 769314a6..dbc328cf 100644
--- a/packages/core/src/core/ui/mute-button/mute-button-core.ts
+++ b/packages/core/src/core/ui/mute-button/mute-button-core.ts
@@ -1,16 +1,16 @@
import { createState } from '@videojs/store';
import { defaults } from '@videojs/utils/object';
-import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaVolumeState } from '../../media/state';
-import type { ButtonState } from '../types';
+import { resolveOptionalControlLabel } from '../resolve-optional-control-label';
+import type { ButtonState, TranslationKeyOrString } from '../types';
export type VolumeLevel = 'off' | 'low' | 'medium' | 'high';
export interface MuteButtonProps {
/** Custom label for the button. */
- label?: string | ((state: MuteButtonState) => string) | undefined;
+ label?: TranslationKeyOrString | ((state: MuteButtonState) => TranslationKeyOrString) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
}
@@ -49,17 +49,11 @@ export class MuteButtonCore {
this.#props = defaults(props, MuteButtonCore.defaultProps);
}
- getLabel(state: MuteButtonState): string {
- const { label } = this.#props;
+ getLabel(state: MuteButtonState): TranslationKeyOrString {
+ const custom = resolveOptionalControlLabel(this.#props.label, state);
+ if (custom !== undefined) return custom;
- if (isFunction(label)) {
- const customLabel = label(state);
- if (customLabel) return customLabel;
- } else if (label) {
- return label;
- }
-
- return state.muted ? 'Unmute' : 'Mute';
+ return state.muted ? 'unmute' : 'mute';
}
getAttrs(state: MuteButtonState) {
diff --git a/packages/core/src/core/ui/mute-button/tests/mute-button-core.test.ts b/packages/core/src/core/ui/mute-button/tests/mute-button-core.test.ts
index d1005565..2b7eee4c 100644
--- a/packages/core/src/core/ui/mute-button/tests/mute-button-core.test.ts
+++ b/packages/core/src/core/ui/mute-button/tests/mute-button-core.test.ts
@@ -80,14 +80,14 @@ describe('MuteButtonCore', () => {
});
describe('getLabel', () => {
- it('returns Mute when unmuted', () => {
+ it('returns mute when unmuted', () => {
const core = new MuteButtonCore();
- expect(core.getLabel(createState({ muted: false }))).toBe('Mute');
+ expect(core.getLabel(createState({ muted: false }))).toBe('mute');
});
- it('returns Unmute when muted', () => {
+ it('returns unmute when muted', () => {
const core = new MuteButtonCore();
- expect(core.getLabel(createState({ muted: true }))).toBe('Unmute');
+ expect(core.getLabel(createState({ muted: true }))).toBe('unmute');
});
it('returns custom string label', () => {
@@ -107,7 +107,7 @@ describe('MuteButtonCore', () => {
it('returns aria-label', () => {
const core = new MuteButtonCore();
const attrs = core.getAttrs(createState({ muted: false }));
- expect(attrs['aria-label']).toBe('Mute');
+ expect(attrs['aria-label']).toBe('mute');
});
it('sets aria-disabled when disabled', () => {
diff --git a/packages/core/src/core/ui/pip-button/pip-button-core.ts b/packages/core/src/core/ui/pip-button/pip-button-core.ts
index 837bd396..7dd18de6 100644
--- a/packages/core/src/core/ui/pip-button/pip-button-core.ts
+++ b/packages/core/src/core/ui/pip-button/pip-button-core.ts
@@ -1,14 +1,14 @@
import { createState } from '@videojs/store';
import { defaults } from '@videojs/utils/object';
-import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaPictureInPictureState } from '../../media/state';
-import type { ButtonState } from '../types';
+import { resolveOptionalControlLabel } from '../resolve-optional-control-label';
+import type { ButtonState, TranslationKeyOrString } from '../types';
export interface PiPButtonProps {
/** Custom label for the button. */
- label?: string | ((state: PiPButtonState) => string) | undefined;
+ label?: TranslationKeyOrString | ((state: PiPButtonState) => TranslationKeyOrString) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
}
@@ -41,17 +41,11 @@ export class PiPButtonCore {
this.#props = defaults(props, PiPButtonCore.defaultProps);
}
- getLabel(state: PiPButtonState): string {
- const { label } = this.#props;
+ getLabel(state: PiPButtonState): TranslationKeyOrString {
+ const custom = resolveOptionalControlLabel(this.#props.label, state);
+ if (custom !== undefined) return custom;
- if (isFunction(label)) {
- const customLabel = label(state);
- if (customLabel) return customLabel;
- } else if (label) {
- return label;
- }
-
- return state.pip ? 'Exit picture-in-picture' : 'Enter picture-in-picture';
+ return state.pip ? 'exitPictureInPicture' : 'enterPictureInPicture';
}
getAttrs(state: PiPButtonState) {
diff --git a/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts b/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts
index 5abddf09..2492c859 100644
--- a/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts
+++ b/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts
@@ -48,12 +48,12 @@ describe('PiPButtonCore', () => {
describe('getLabel', () => {
it('returns Enter picture-in-picture when not in PiP', () => {
const core = new PiPButtonCore();
- expect(core.getLabel(createState({ pip: false }))).toBe('Enter picture-in-picture');
+ expect(core.getLabel(createState({ pip: false }))).toBe('enterPictureInPicture');
});
it('returns Exit picture-in-picture when in PiP', () => {
const core = new PiPButtonCore();
- expect(core.getLabel(createState({ pip: true }))).toBe('Exit picture-in-picture');
+ expect(core.getLabel(createState({ pip: true }))).toBe('exitPictureInPicture');
});
it('returns custom string label', () => {
@@ -73,7 +73,7 @@ describe('PiPButtonCore', () => {
it('returns aria-label', () => {
const core = new PiPButtonCore();
const attrs = core.getAttrs(createState());
- expect(attrs['aria-label']).toBe('Enter picture-in-picture');
+ expect(attrs['aria-label']).toBe('enterPictureInPicture');
});
it('sets aria-disabled when disabled', () => {
diff --git a/packages/core/src/core/ui/play-button/play-button-core.ts b/packages/core/src/core/ui/play-button/play-button-core.ts
index c091644a..b5d95842 100644
--- a/packages/core/src/core/ui/play-button/play-button-core.ts
+++ b/packages/core/src/core/ui/play-button/play-button-core.ts
@@ -1,14 +1,14 @@
import { createState } from '@videojs/store';
import { defaults } from '@videojs/utils/object';
-import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaPlaybackState } from '../../media/state';
-import type { ButtonState } from '../types';
+import { resolveOptionalControlLabel } from '../resolve-optional-control-label';
+import type { ButtonState, TranslationKeyOrString } from '../types';
export interface PlayButtonProps {
/** Custom label for the button. */
- label?: string | ((state: PlayButtonState) => string) | undefined;
+ label?: TranslationKeyOrString | ((state: PlayButtonState) => TranslationKeyOrString) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
}
@@ -39,18 +39,12 @@ export class PlayButtonCore {
this.#props = defaults(props, PlayButtonCore.defaultProps);
}
- getLabel(state: PlayButtonState): string {
- const { label } = this.#props;
+ getLabel(state: PlayButtonState): TranslationKeyOrString {
+ const custom = resolveOptionalControlLabel(this.#props.label, state);
+ if (custom !== undefined) return custom;
- if (isFunction(label)) {
- const customLabel = label(state);
- if (customLabel) return customLabel;
- } else if (label) {
- return label;
- }
-
- if (state.ended) return 'Replay';
- return state.paused ? 'Play' : 'Pause';
+ if (state.ended) return 'replay';
+ return state.paused ? 'play' : 'pause';
}
getAttrs(state: PlayButtonState) {
diff --git a/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts b/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts
index 47e15959..cc41c750 100644
--- a/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts
+++ b/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts
@@ -65,19 +65,19 @@ describe('PlayButtonCore', () => {
});
describe('getLabel', () => {
- it('returns Play when paused', () => {
+ it('returns play when paused', () => {
const core = new PlayButtonCore();
- expect(core.getLabel(createState({ paused: true }))).toBe('Play');
+ expect(core.getLabel(createState({ paused: true }))).toBe('play');
});
- it('returns Pause when playing', () => {
+ it('returns pause when playing', () => {
const core = new PlayButtonCore();
- expect(core.getLabel(createState({ paused: false }))).toBe('Pause');
+ expect(core.getLabel(createState({ paused: false }))).toBe('pause');
});
- it('returns Replay when ended', () => {
+ it('returns replay when ended', () => {
const core = new PlayButtonCore();
- expect(core.getLabel(createState({ ended: true }))).toBe('Replay');
+ expect(core.getLabel(createState({ ended: true }))).toBe('replay');
});
it('returns custom string label', () => {
@@ -94,7 +94,7 @@ describe('PlayButtonCore', () => {
it('falls back to default when function returns empty', () => {
const core = new PlayButtonCore({ label: () => '' });
- expect(core.getLabel(createState({ paused: true }))).toBe('Play');
+ expect(core.getLabel(createState({ paused: true }))).toBe('play');
});
});
@@ -102,7 +102,7 @@ describe('PlayButtonCore', () => {
it('returns aria-label', () => {
const core = new PlayButtonCore();
const attrs = core.getAttrs(createState({ paused: true }));
- expect(attrs['aria-label']).toBe('Play');
+ expect(attrs['aria-label']).toBe('play');
});
it('sets aria-disabled when disabled', () => {
diff --git a/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts b/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts
index 71cd4c35..a9116039 100644
--- a/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts
+++ b/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts
@@ -1,14 +1,14 @@
import { createState } from '@videojs/store';
import { defaults } from '@videojs/utils/object';
-import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaPlaybackRateState } from '../../media/state';
-import type { ButtonState } from '../types';
+import { createOptionalControlLabelCache } from '../resolve-optional-control-label';
+import type { ButtonState, TranslationKeyOrString } from '../types';
export interface PlaybackRateButtonProps {
/** Custom label for the button. */
- label?: string | ((state: PlaybackRateButtonState) => string) | undefined;
+ label?: TranslationKeyOrString | ((state: PlaybackRateButtonState) => TranslationKeyOrString) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
/** When true, pointer activation opens a menu instead of cycling. React sets this automatically inside `Menu.Trigger`. */
@@ -33,6 +33,7 @@ export class PlaybackRateButtonCore {
#props = { ...PlaybackRateButtonCore.defaultProps };
#media: MediaPlaybackRateState | null = null;
+ readonly #customLabel = createOptionalControlLabelCache();
constructor(props?: PlaybackRateButtonProps) {
if (props) this.setProps(props);
@@ -40,19 +41,19 @@ export class PlaybackRateButtonCore {
setProps(props: PlaybackRateButtonProps): void {
this.#props = defaults(props, PlaybackRateButtonCore.defaultProps);
+ this.#customLabel.invalidate();
}
- getLabel(state: PlaybackRateButtonState): string {
- const { label } = this.#props;
+ getLabel(state: PlaybackRateButtonState): TranslationKeyOrString {
+ const custom = this.#customLabel.resolve(this.#props.label, state);
+ if (custom !== undefined) return custom;
- if (isFunction(label)) {
- const customLabel = label(state);
- if (customLabel) return customLabel;
- } else if (label) {
- return label;
- }
+ return 'playbackRateAria';
+ }
- return `Playback rate ${state.rate}`;
+ getLabelParams(state: PlaybackRateButtonState): { rate: number } | undefined {
+ if (this.#customLabel.resolve(this.#props.label, state) !== undefined) return undefined;
+ return { rate: state.rate };
}
getAttrs(state: PlaybackRateButtonState) {
diff --git a/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts b/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts
index cc281116..78a31496 100644
--- a/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts
+++ b/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts
@@ -36,12 +36,12 @@ describe('PlaybackRateButtonCore', () => {
describe('getLabel', () => {
it('returns default label with rate', () => {
const core = new PlaybackRateButtonCore();
- expect(core.getLabel(createState({ rate: 1.5 }))).toBe('Playback rate 1.5');
+ expect(core.getLabel(createState({ rate: 1.5 }))).toBe('playbackRateAria');
});
it('returns default label for rate 1', () => {
const core = new PlaybackRateButtonCore();
- expect(core.getLabel(createState({ rate: 1 }))).toBe('Playback rate 1');
+ expect(core.getLabel(createState({ rate: 1 }))).toBe('playbackRateAria');
});
it('returns custom string label', () => {
@@ -60,7 +60,19 @@ describe('PlaybackRateButtonCore', () => {
const core = new PlaybackRateButtonCore({
label: () => '',
});
- expect(core.getLabel(createState({ rate: 1.5 }))).toBe('Playback rate 1.5');
+ expect(core.getLabel(createState({ rate: 1.5 }))).toBe('playbackRateAria');
+ });
+ });
+
+ describe('getLabelParams', () => {
+ it('returns rate for default label', () => {
+ const core = new PlaybackRateButtonCore();
+ expect(core.getLabelParams(createState({ rate: 1.5 }))).toEqual({ rate: 1.5 });
+ });
+
+ it('returns undefined when custom label is set', () => {
+ const core = new PlaybackRateButtonCore({ label: 'Speed' });
+ expect(core.getLabelParams(createState())).toBeUndefined();
});
});
@@ -68,7 +80,7 @@ describe('PlaybackRateButtonCore', () => {
it('returns aria-label', () => {
const core = new PlaybackRateButtonCore();
const attrs = core.getAttrs(createState({ rate: 1.5 }));
- expect(attrs['aria-label']).toBe('Playback rate 1.5');
+ expect(attrs['aria-label']).toBe('playbackRateAria');
});
it('sets aria-disabled when disabled', () => {
diff --git a/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-core.ts b/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-core.ts
index e582c99a..95377aef 100644
--- a/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-core.ts
+++ b/packages/core/src/core/ui/playback-rate-radio-group/playback-rate-radio-group-core.ts
@@ -1,14 +1,15 @@
import { createState } from '@videojs/store';
import { defaults } from '@videojs/utils/object';
-import { isFunction, isUndefined } from '@videojs/utils/predicate';
+import { isUndefined } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaPlaybackRateState } from '../../media/state';
-import type { ButtonState } from '../types';
+import { createOptionalControlLabelCache } from '../resolve-optional-control-label';
+import type { ButtonState, TranslationKeyOrString } from '../types';
export interface PlaybackRateRadioGroupProps {
/** Custom label for the options group. */
- label?: string | ((state: PlaybackRateRadioGroupState) => string) | undefined;
+ label?: TranslationKeyOrString | ((state: PlaybackRateRadioGroupState) => TranslationKeyOrString) | undefined;
/** Custom formatter for visible playback rate labels. */
formatRate?: ((rate: number) => string) | undefined;
/** Whether playback rate selection is disabled. */
@@ -43,6 +44,7 @@ export class PlaybackRateRadioGroupCore {
#props = { ...PlaybackRateRadioGroupCore.defaultProps };
#media: MediaPlaybackRateState | null = null;
+ readonly #customLabel = createOptionalControlLabelCache();
constructor(props?: PlaybackRateRadioGroupProps) {
if (props) this.setProps(props);
@@ -50,19 +52,18 @@ export class PlaybackRateRadioGroupCore {
setProps(props: PlaybackRateRadioGroupProps): void {
this.#props = defaults(props, PlaybackRateRadioGroupCore.defaultProps);
+ this.#customLabel.invalidate();
}
- getLabel(state: PlaybackRateRadioGroupState): string {
- const { label } = this.#props;
+ getLabel(state: PlaybackRateRadioGroupState): TranslationKeyOrString {
+ const custom = this.#customLabel.resolve(this.#props.label, state);
+ if (custom !== undefined) return custom;
+ return 'playbackRateAria';
+ }
- if (isFunction(label)) {
- const customLabel = label(state);
- if (customLabel) return customLabel;
- } else if (label) {
- return label;
- }
-
- return `Playback rate ${state.rate}`;
+ getLabelParams(state: PlaybackRateRadioGroupState): { rate: number } | undefined {
+ if (this.#customLabel.resolve(this.#props.label, state) !== undefined) return undefined;
+ return { rate: state.rate };
}
getRateLabel(rate: number): string {
diff --git a/packages/core/src/core/ui/playback-rate-radio-group/tests/playback-rate-radio-group-core.test.ts b/packages/core/src/core/ui/playback-rate-radio-group/tests/playback-rate-radio-group-core.test.ts
index 3dd48ef8..832d9331 100644
--- a/packages/core/src/core/ui/playback-rate-radio-group/tests/playback-rate-radio-group-core.test.ts
+++ b/packages/core/src/core/ui/playback-rate-radio-group/tests/playback-rate-radio-group-core.test.ts
@@ -62,7 +62,7 @@ describe('PlaybackRateRadioGroupCore', () => {
describe('getLabel', () => {
it('returns default label with rate', () => {
const core = new PlaybackRateRadioGroupCore();
- expect(core.getLabel(createState({ rate: 1.5 }))).toBe('Playback rate 1.5');
+ expect(core.getLabel(createState({ rate: 1.5 }))).toBe('playbackRateAria');
});
it('returns custom string label', () => {
@@ -78,6 +78,18 @@ describe('PlaybackRateRadioGroupCore', () => {
});
});
+ describe('getLabelParams', () => {
+ it('returns rate for default label', () => {
+ const core = new PlaybackRateRadioGroupCore();
+ expect(core.getLabelParams(createState({ rate: 2 }))).toEqual({ rate: 2 });
+ });
+
+ it('returns undefined when custom label is set', () => {
+ const core = new PlaybackRateRadioGroupCore({ label: 'Speed' });
+ expect(core.getLabelParams(createState())).toBeUndefined();
+ });
+ });
+
describe('getRateLabel', () => {
it('formats rate labels by default', () => {
const core = new PlaybackRateRadioGroupCore();
@@ -97,7 +109,7 @@ describe('PlaybackRateRadioGroupCore', () => {
it('returns aria-label', () => {
const core = new PlaybackRateRadioGroupCore();
const attrs = core.getAttrs(createState({ rate: 1.5 }));
- expect(attrs['aria-label']).toBe('Playback rate 1.5');
+ expect(attrs['aria-label']).toBe('playbackRateAria');
});
it('sets aria-disabled when disabled', () => {
diff --git a/packages/core/src/core/ui/resolve-control-attrs.ts b/packages/core/src/core/ui/resolve-control-attrs.ts
new file mode 100644
index 00000000..86f0932a
--- /dev/null
+++ b/packages/core/src/core/ui/resolve-control-attrs.ts
@@ -0,0 +1,58 @@
+import { resolveTranslationPhrase } from '../i18n/resolve-translation-phrase';
+import type { Translator } from '../i18n/types';
+import type { TranslationKeyOrString } from './types';
+
+export interface ControlAttrsResolvable {
+ getAttrs?(state: State): object;
+ getLabelParams?(state: State): Record | undefined;
+ getValueTextParams?(state: State): Record | undefined;
+}
+
+export interface ControlLabelResolvable {
+ getLabel(state: State): TranslationKeyOrString;
+ getLabelParams?(state: State): Record | undefined;
+}
+
+/** Resolves `getLabel` and optional {@link ControlLabelResolvable.getLabelParams}. */
+export function resolveControlLabel(
+ translator: Translator,
+ core: ControlLabelResolvable,
+ state: State
+): string {
+ return resolveTranslationPhrase(translator, core.getLabel(state), core.getLabelParams?.(state));
+}
+
+/** Resolves `aria-label` / `aria-valuetext` keys from {@link ControlAttrsResolvable.getAttrs}. */
+export function resolveControlAttrs(
+ translator: Translator,
+ core: ControlAttrsResolvable,
+ state: State
+): Record {
+ const raw = (core.getAttrs?.(state) ?? {}) as Record;
+ const out: Record = {};
+
+ for (const [key, value] of Object.entries(raw)) {
+ if (value === undefined) {
+ out[key] = undefined;
+ continue;
+ }
+
+ if (key === 'aria-label' && typeof value === 'string') {
+ out[key] = resolveTranslationPhrase(translator, value as TranslationKeyOrString, core.getLabelParams?.(state));
+ continue;
+ }
+
+ if (key === 'aria-valuetext' && typeof value === 'string') {
+ out[key] = resolveTranslationPhrase(
+ translator,
+ value as TranslationKeyOrString,
+ core.getValueTextParams?.(state)
+ );
+ continue;
+ }
+
+ out[key] = String(value);
+ }
+
+ return out;
+}
diff --git a/packages/core/src/core/ui/resolve-optional-control-label.ts b/packages/core/src/core/ui/resolve-optional-control-label.ts
new file mode 100644
index 00000000..47d1d18e
--- /dev/null
+++ b/packages/core/src/core/ui/resolve-optional-control-label.ts
@@ -0,0 +1,46 @@
+import { isFunction } from '@videojs/utils/predicate';
+
+import type { TranslationKeyOrString } from './types';
+
+/**
+ * Resolves a user-provided control `label` prop ({@link TranslationKeyOrString} or state callback).
+ * Returns `undefined` when the caller should use its built-in default (registry id, slider default, etc.).
+ */
+export function resolveOptionalControlLabel(
+ label: TranslationKeyOrString | ((state: State) => TranslationKeyOrString) | undefined,
+ state: State
+): TranslationKeyOrString | undefined {
+ if (isFunction(label)) {
+ const custom = label(state);
+ return custom ? custom : undefined;
+ }
+ if (label) return label;
+ return undefined;
+}
+
+/** Per-instance cache so `getLabel` / `getLabelParams` share one resolution per state snapshot. */
+export function createOptionalControlLabelCache(): {
+ resolve(
+ label: TranslationKeyOrString | ((state: State) => TranslationKeyOrString) | undefined,
+ state: State
+ ): TranslationKeyOrString | undefined;
+ invalidate(): void;
+} {
+ let cache: {
+ state: State;
+ label: TranslationKeyOrString | ((state: State) => TranslationKeyOrString) | undefined;
+ custom: TranslationKeyOrString | undefined;
+ } | null = null;
+
+ return {
+ resolve(label, state) {
+ if (cache?.state !== state || cache.label !== label) {
+ cache = { state, label, custom: resolveOptionalControlLabel(label, state) };
+ }
+ return cache.custom;
+ },
+ invalidate() {
+ cache = null;
+ },
+ };
+}
diff --git a/packages/core/src/core/ui/seek-button/seek-button-core.ts b/packages/core/src/core/ui/seek-button/seek-button-core.ts
index c4062016..b856bce9 100644
--- a/packages/core/src/core/ui/seek-button/seek-button-core.ts
+++ b/packages/core/src/core/ui/seek-button/seek-button-core.ts
@@ -1,16 +1,16 @@
import { createState } from '@videojs/store';
import { defaults } from '@videojs/utils/object';
-import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaTimeState } from '../../media/state';
-import type { ButtonState } from '../types';
+import { createOptionalControlLabelCache } from '../resolve-optional-control-label';
+import type { ButtonState, TranslationKeyOrString } from '../types';
export interface SeekButtonProps {
/** Seconds to seek. Positive = forward, negative = backward. Default `30`. */
seconds?: number | undefined;
/** Custom label for the button. */
- label?: string | ((state: SeekButtonState) => string) | undefined;
+ label?: TranslationKeyOrString | ((state: SeekButtonState) => TranslationKeyOrString) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
}
@@ -39,6 +39,7 @@ export class SeekButtonCore {
#props = { ...SeekButtonCore.defaultProps };
#media: MediaTimeState | null = null;
+ readonly #customLabel = createOptionalControlLabelCache();
constructor(props?: SeekButtonProps) {
if (props) this.setProps(props);
@@ -46,20 +47,19 @@ export class SeekButtonCore {
setProps(props: SeekButtonProps): void {
this.#props = defaults(props, SeekButtonCore.defaultProps);
+ this.#customLabel.invalidate();
}
- getLabel(state: SeekButtonState): string {
- const { label } = this.#props;
+ getLabel(state: SeekButtonState): TranslationKeyOrString {
+ const custom = this.#customLabel.resolve(this.#props.label, state);
+ if (custom !== undefined) return custom;
- if (isFunction(label)) {
- const customLabel = label(state);
- if (customLabel) return customLabel;
- } else if (label) {
- return label;
- }
+ return state.direction === 'backward' ? 'seekBackward' : 'seekForward';
+ }
- const abs = Math.abs(this.#props.seconds);
- return state.direction === 'backward' ? `Seek backward ${abs} seconds` : `Seek forward ${abs} seconds`;
+ getLabelParams(state: SeekButtonState): { seconds: number } | undefined {
+ if (this.#customLabel.resolve(this.#props.label, state) !== undefined) return undefined;
+ return { seconds: Math.abs(this.#props.seconds) };
}
getAttrs(state: SeekButtonState) {
diff --git a/packages/core/src/core/ui/seek-button/tests/seek-button-core.test.ts b/packages/core/src/core/ui/seek-button/tests/seek-button-core.test.ts
index 334a0f6d..505a3c39 100644
--- a/packages/core/src/core/ui/seek-button/tests/seek-button-core.test.ts
+++ b/packages/core/src/core/ui/seek-button/tests/seek-button-core.test.ts
@@ -75,18 +75,18 @@ describe('SeekButtonCore', () => {
describe('getLabel', () => {
it('returns forward label for forward direction', () => {
const core = new SeekButtonCore({ seconds: 30 });
- expect(core.getLabel(createState({ direction: 'forward' }))).toBe('Seek forward 30 seconds');
+ expect(core.getLabel(createState({ direction: 'forward' }))).toBe('seekForward');
});
it('returns backward label for backward direction', () => {
const core = new SeekButtonCore({ seconds: -10 });
- expect(core.getLabel(createState({ direction: 'backward' }))).toBe('Seek backward 10 seconds');
+ expect(core.getLabel(createState({ direction: 'backward' }))).toBe('seekBackward');
});
it('uses absolute value in backward label', () => {
const core = new SeekButtonCore({ seconds: -30 });
const label = core.getLabel(createState({ direction: 'backward' }));
- expect(label).toBe('Seek backward 30 seconds');
+ expect(label).toBe('seekBackward');
expect(label).not.toContain('-');
});
@@ -105,7 +105,40 @@ describe('SeekButtonCore', () => {
it('falls back to default when function returns empty', () => {
const core = new SeekButtonCore({ seconds: 10, label: () => '' });
- expect(core.getLabel(createState({ direction: 'forward' }))).toBe('Seek forward 10 seconds');
+ expect(core.getLabel(createState({ direction: 'forward' }))).toBe('seekForward');
+ });
+ });
+
+ describe('getLabelParams', () => {
+ it('returns seconds for default forward label', () => {
+ const core = new SeekButtonCore({ seconds: 30 });
+ expect(core.getLabelParams(createState({ direction: 'forward' }))).toEqual({ seconds: 30 });
+ });
+
+ it('returns positive seconds for backward seek', () => {
+ const core = new SeekButtonCore({ seconds: -10 });
+ expect(core.getLabelParams(createState({ direction: 'backward' }))).toEqual({ seconds: 10 });
+ });
+
+ it('returns undefined when custom label is set', () => {
+ const core = new SeekButtonCore({ label: 'Skip' });
+ expect(core.getLabelParams(createState())).toBeUndefined();
+ });
+
+ it('shares one label callback invocation with getLabel for the same state', () => {
+ let calls = 0;
+ const core = new SeekButtonCore({
+ seconds: 30,
+ label: () => {
+ calls += 1;
+ return calls === 1 ? 'custom' : '';
+ },
+ });
+ const state = createState({ direction: 'forward' });
+
+ expect(core.getLabel(state)).toBe('custom');
+ expect(core.getLabelParams(state)).toBeUndefined();
+ expect(calls).toBe(1);
});
});
@@ -113,7 +146,7 @@ describe('SeekButtonCore', () => {
it('returns aria-label', () => {
const core = new SeekButtonCore({ seconds: 30 });
const attrs = core.getAttrs(createState({ direction: 'forward' }));
- expect(attrs['aria-label']).toBe('Seek forward 30 seconds');
+ expect(attrs['aria-label']).toBe('seekForward');
});
it('sets aria-disabled when disabled', () => {
diff --git a/packages/core/src/core/ui/slider/slider-core.ts b/packages/core/src/core/ui/slider/slider-core.ts
index cc81afbc..78eb3d8f 100644
--- a/packages/core/src/core/ui/slider/slider-core.ts
+++ b/packages/core/src/core/ui/slider/slider-core.ts
@@ -1,12 +1,13 @@
import { clamp, roundToStep } from '@videojs/utils/number';
import { defaults } from '@videojs/utils/object';
-import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
+import { resolveOptionalControlLabel } from '../resolve-optional-control-label';
+import type { TranslationKeyOrString } from '../types';
/** Configuration shared by all slider variants. */
export interface SliderProps {
/** Custom label for the slider. */
- label?: string | ((state: SliderState) => string) | undefined;
+ label?: TranslationKeyOrString | ((state: SliderState) => TranslationKeyOrString) | undefined;
/** Step increment for value changes (arrow keys). */
step?: number | undefined;
/** Large step increment (Page Up/Down keys). */
@@ -122,17 +123,8 @@ export class SliderCore {
};
}
- getLabel(state: SliderState): string {
- const { label } = this.#props;
-
- if (isFunction(label)) {
- const customLabel = label(state);
- if (customLabel) return customLabel;
- } else if (label) {
- return label;
- }
-
- return '';
+ getLabel(state: SliderState): TranslationKeyOrString {
+ return resolveOptionalControlLabel(this.#props.label, state) ?? '';
}
getAttrs(state: SliderState) {
diff --git a/packages/core/src/core/ui/tests/resolve-control-attrs.test.ts b/packages/core/src/core/ui/tests/resolve-control-attrs.test.ts
new file mode 100644
index 00000000..91f32f13
--- /dev/null
+++ b/packages/core/src/core/ui/tests/resolve-control-attrs.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+
+import { createTranslator, translations } from '../../i18n';
+import { resolveControlAttrs } from '../resolve-control-attrs';
+
+describe('resolveControlAttrs', () => {
+ const translator = createTranslator(translations, 'en');
+
+ it('resolves aria-label and aria-valuetext with params', () => {
+ const core = {
+ getAttrs: () => ({
+ role: 'slider',
+ 'aria-label': 'volume',
+ 'aria-valuetext': 'volumeSliderValueTextMuted',
+ }),
+ getValueTextParams: () => ({ percent: '50%' }),
+ };
+
+ expect(resolveControlAttrs(translator, core, {})).toEqual({
+ role: 'slider',
+ 'aria-label': 'Volume',
+ 'aria-valuetext': '50%, muted',
+ });
+ });
+
+ it('resolves time slider range valuetext', () => {
+ const core = {
+ getAttrs: () => ({
+ 'aria-valuetext': 'timeSliderValueTextRange',
+ }),
+ getValueTextParams: () => ({ current: '1 minute', duration: '5 minutes' }),
+ };
+
+ expect(resolveControlAttrs(translator, core, {})['aria-valuetext']).toBe('1 minute of 5 minutes');
+ });
+});
diff --git a/packages/core/src/core/ui/tests/resolve-optional-control-label.test.ts b/packages/core/src/core/ui/tests/resolve-optional-control-label.test.ts
new file mode 100644
index 00000000..4a354172
--- /dev/null
+++ b/packages/core/src/core/ui/tests/resolve-optional-control-label.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest';
+
+import { createOptionalControlLabelCache, resolveOptionalControlLabel } from '../resolve-optional-control-label';
+
+describe('resolveOptionalControlLabel', () => {
+ const state = { x: 1 as const };
+
+ it('returns undefined when label is missing', () => {
+ expect(resolveOptionalControlLabel(undefined, state)).toBeUndefined();
+ });
+
+ it('returns undefined for empty string label', () => {
+ expect(resolveOptionalControlLabel('', state)).toBeUndefined();
+ });
+
+ it('returns string label when set', () => {
+ expect(resolveOptionalControlLabel('Custom', state)).toBe('Custom');
+ });
+
+ it('returns callback result when truthy', () => {
+ expect(resolveOptionalControlLabel(() => 'From state', state)).toBe('From state');
+ expect(resolveOptionalControlLabel((s) => `x=${s.x}`, state)).toBe('x=1');
+ });
+
+ it('returns undefined when callback returns empty string', () => {
+ expect(resolveOptionalControlLabel(() => '', state)).toBeUndefined();
+ });
+});
+
+describe('createOptionalControlLabelCache', () => {
+ const state = { x: 1 as const };
+
+ it('reuses resolution for the same state snapshot', () => {
+ let calls = 0;
+ const cache = createOptionalControlLabelCache();
+ const label = () => {
+ calls += 1;
+ return calls === 1 ? 'first' : '';
+ };
+
+ expect(cache.resolve(label, state)).toBe('first');
+ expect(cache.resolve(label, state)).toBe('first');
+ expect(calls).toBe(1);
+ });
+
+ it('invalidates cached resolution', () => {
+ const cache = createOptionalControlLabelCache();
+
+ expect(cache.resolve('A', state)).toBe('A');
+ cache.invalidate();
+ expect(cache.resolve('B', state)).toBe('B');
+ });
+
+ it('re-resolves when label changes for the same state snapshot', () => {
+ const cache = createOptionalControlLabelCache();
+
+ expect(cache.resolve('A', state)).toBe('A');
+ expect(cache.resolve('B', state)).toBe('B');
+ });
+});
diff --git a/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts b/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts
index a8e91a41..5d114bf4 100644
--- a/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts
+++ b/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts
@@ -1,3 +1,4 @@
+import { formatDuration } from '@videojs/utils/time';
import { describe, expect, it, vi } from 'vitest';
import type { MediaBufferState, MediaTimeState } from '../../../media/state';
@@ -33,7 +34,7 @@ describe('TimeSliderCore', () => {
describe('defaultProps', () => {
it('has expected defaults', () => {
expect(TimeSliderCore.defaultProps).toEqual({
- label: 'Seek',
+ label: '',
step: 1,
largeStep: 10,
orientation: 'horizontal',
@@ -156,8 +157,12 @@ describe('TimeSliderCore', () => {
const state = core.getState();
const attrs = core.getAttrs(state);
- expect(attrs['aria-label']).toBe('Seek');
- expect(attrs['aria-valuetext']).toBe('1 minute, 30 seconds of 5 minutes');
+ expect(attrs['aria-label']).toBe('seek');
+ expect(attrs['aria-valuetext']).toBe('timeSliderValueTextRange');
+ expect(core.getValueTextParams(state)).toEqual({
+ current: formatDuration(90),
+ duration: formatDuration(300),
+ });
expect(attrs.role).toBe('slider');
});
@@ -178,7 +183,11 @@ describe('TimeSliderCore', () => {
const state = core.getState();
const attrs = core.getAttrs(state);
- expect(attrs['aria-valuetext']).toBe('0 seconds of 0 seconds');
+ expect(attrs['aria-valuetext']).toBe('timeSliderValueTextRange');
+ expect(core.getValueTextParams(state)).toEqual({
+ current: formatDuration(0),
+ duration: formatDuration(0),
+ });
});
it('announces drag position in valuetext during drag', () => {
@@ -188,9 +197,12 @@ describe('TimeSliderCore', () => {
const state = core.getState();
const attrs = core.getAttrs(state);
- // pointerPercent is 50 → 150s → "2 minutes, 30 seconds of 5 minutes"
expect(attrs['aria-valuenow']).toBe(150);
- expect(attrs['aria-valuetext']).toBe('2 minutes, 30 seconds of 5 minutes');
+ expect(attrs['aria-valuetext']).toBe('timeSliderValueTextRange');
+ expect(core.getValueTextParams(state)).toEqual({
+ current: formatDuration(150),
+ duration: formatDuration(300),
+ });
});
});
diff --git a/packages/core/src/core/ui/time-slider/time-slider-core.ts b/packages/core/src/core/ui/time-slider/time-slider-core.ts
index 2620180a..8a8f63dd 100644
--- a/packages/core/src/core/ui/time-slider/time-slider-core.ts
+++ b/packages/core/src/core/ui/time-slider/time-slider-core.ts
@@ -1,9 +1,10 @@
import { defaults } from '@videojs/utils/object';
-import { formatTimeAsPhrase } from '@videojs/utils/time';
+import { formatDuration, type TimeFormatOptions } from '@videojs/utils/time';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaBufferState, MediaTimeState } from '../../media/state';
import { SliderCore, type SliderProps, type SliderState } from '../slider/slider-core';
+import type { TranslationKeyOrString } from '../types';
export interface TimeSliderProps extends SliderProps {
/** @internal Derived from `currentTime` — not user-settable. */
@@ -14,6 +15,8 @@ export interface TimeSliderProps extends SliderProps {
max?: number | undefined;
/** Leading+trailing throttle (ms) for `onValueChange` during drag. */
changeThrottle?: number | undefined;
+ /** Options for `formatDuration` when building the slider thumb `aria-valuetext`. */
+ formatOptions?: TimeFormatOptions | undefined;
}
export interface TimeSliderState extends SliderState, Pick {
@@ -23,13 +26,13 @@ export interface TimeSliderState extends SliderState, Pick = {
+ static override readonly defaultProps: NonNullableObject> = {
...SliderCore.defaultProps,
- label: 'Seek',
+ label: '',
changeThrottle: 100,
};
- #props = { ...TimeSliderCore.defaultProps };
+ #props: TimeSliderProps = { ...TimeSliderCore.defaultProps };
#media: (MediaTimeState & MediaBufferState) | null = null;
constructor(props?: TimeSliderProps) {
@@ -68,23 +71,38 @@ export class TimeSliderCore extends SliderCore {
};
}
- override getLabel(state: SliderState): string {
- return super.getLabel(state) || 'Seek';
+ override getLabel(state: SliderState): TranslationKeyOrString {
+ return super.getLabel(state) || 'seek';
+ }
+
+ #announceValue(state: TimeSliderState): number {
+ return state.dragging ? this.rawValueFromPercent(state.pointerPercent) : state.value;
+ }
+
+ getValueText(state: TimeSliderState): TranslationKeyOrString {
+ return Number.isFinite(state.duration) ? 'timeSliderValueTextRange' : this.getValueTextParams(state).current;
+ }
+
+ getValueTextParams(state: TimeSliderState): { current: string; duration: string } | { current: string } {
+ const formatOptions = this.#props.formatOptions;
+ const current = formatDuration(this.#announceValue(state), formatOptions);
+ if (!Number.isFinite(state.duration)) {
+ return { current };
+ }
+ return {
+ current,
+ duration: formatDuration(state.duration, formatOptions),
+ };
}
override getAttrs(state: TimeSliderState) {
const base = super.getAttrs(state);
-
- // During drag, announce the pointer position the user would seek to.
- const announceValue = state.dragging ? this.rawValueFromPercent(state.pointerPercent) : state.value;
- const currentPhrase = formatTimeAsPhrase(announceValue);
- const durationPhrase = formatTimeAsPhrase(state.duration);
- const valuetext = durationPhrase ? `${currentPhrase} of ${durationPhrase}` : currentPhrase;
+ const announceValue = this.#announceValue(state);
return {
...base,
'aria-valuenow': announceValue,
- 'aria-valuetext': valuetext,
+ 'aria-valuetext': this.getValueText(state),
};
}
}
diff --git a/packages/core/src/core/ui/time/tests/time-core.test.ts b/packages/core/src/core/ui/time/tests/time-core.test.ts
index 7122ede5..136b8489 100644
--- a/packages/core/src/core/ui/time/tests/time-core.test.ts
+++ b/packages/core/src/core/ui/time/tests/time-core.test.ts
@@ -1,3 +1,4 @@
+import { formatDuration } from '@videojs/utils/time';
import { describe, expect, it } from 'vitest';
import type { MediaTimeState } from '../../../media/state';
@@ -40,7 +41,7 @@ describe('TimeCore', () => {
expect(state.seconds).toBe(90);
expect(state.negative).toBe(false);
expect(state.text).toBe('1:30');
- expect(state.phrase).toBe('1 minute, 30 seconds');
+ expect(state.phrase).toBe(formatDuration(90));
expect(state.datetime).toBe('PT1M30S');
});
@@ -53,7 +54,7 @@ describe('TimeCore', () => {
expect(state.seconds).toBe(300);
expect(state.negative).toBe(false);
expect(state.text).toBe('5:00');
- expect(state.phrase).toBe('5 minutes');
+ expect(state.phrase).toBe(formatDuration(300));
expect(state.datetime).toBe('PT5M');
});
@@ -66,7 +67,7 @@ describe('TimeCore', () => {
expect(state.seconds).toBe(-210); // 90 - 300
expect(state.negative).toBe(true);
expect(state.text).toBe('3:30');
- expect(state.phrase).toBe('3 minutes, 30 seconds remaining');
+ expect(state.phrase).toBe(formatDuration(90 - 300));
expect(state.datetime).toBe('PT3M30S');
});
@@ -103,21 +104,21 @@ describe('TimeCore', () => {
const core = new TimeCore({ type: 'current' });
core.setMedia(createMediaState());
const state = core.getState();
- expect(core.getLabel(state)).toBe('Current time');
+ expect(core.getLabel(state)).toBe('timeCurrent');
});
it('returns default label for duration', () => {
const core = new TimeCore({ type: 'duration' });
core.setMedia(createMediaState());
const state = core.getState();
- expect(core.getLabel(state)).toBe('Duration');
+ expect(core.getLabel(state)).toBe('timeDuration');
});
it('returns default label for remaining', () => {
const core = new TimeCore({ type: 'remaining' });
core.setMedia(createMediaState());
const state = core.getState();
- expect(core.getLabel(state)).toBe('Remaining');
+ expect(core.getLabel(state)).toBe('timeRemaining');
});
it('returns custom string label', () => {
@@ -145,8 +146,8 @@ describe('TimeCore', () => {
const state = core.getState();
const attrs = core.getAttrs(state);
- expect(attrs['aria-label']).toBe('Current time');
- expect(attrs['aria-valuetext']).toBe('1 minute, 30 seconds');
+ expect(attrs['aria-label']).toBe('timeCurrent');
+ expect(attrs['aria-valuetext']).toBe(formatDuration(90));
});
it('includes remaining suffix in valuetext', () => {
@@ -155,8 +156,22 @@ describe('TimeCore', () => {
const state = core.getState();
const attrs = core.getAttrs(state);
- expect(attrs['aria-label']).toBe('Remaining');
- expect(attrs['aria-valuetext']).toBe('3 minutes, 30 seconds remaining');
+ expect(attrs['aria-label']).toBe('timeRemaining');
+ expect(attrs['aria-valuetext']).toBe(formatDuration(90 - 300));
+ });
+
+ it('uses formatRemaining for remaining phrase when provided', () => {
+ const core = new TimeCore({
+ type: 'remaining',
+ formatOptions: {
+ locale: 'en',
+ formatRemaining: (duration) => `quedan ${duration}`,
+ },
+ });
+ core.setMedia(createMediaState({ currentTime: 60, duration: 120 }));
+ const state = core.getState();
+
+ expect(state.phrase.startsWith('quedan ')).toBe(true);
});
});
});
diff --git a/packages/core/src/core/ui/time/time-core.ts b/packages/core/src/core/ui/time/time-core.ts
index 818c402e..db5eb331 100644
--- a/packages/core/src/core/ui/time/time-core.ts
+++ b/packages/core/src/core/ui/time/time-core.ts
@@ -1,9 +1,10 @@
import { defaults } from '@videojs/utils/object';
-import { isFunction } from '@videojs/utils/predicate';
-import { formatTime, formatTimeAsPhrase, secondsToIsoDuration } from '@videojs/utils/time';
+import { formatDuration, formatTime, secondsToIsoDuration, type TimeFormatOptions } from '@videojs/utils/time';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaTimeState } from '../../media/state';
+import { resolveOptionalControlLabel } from '../resolve-optional-control-label';
+import type { TranslationKeyOrString } from '../types';
/** Time display type. */
export type TimeType = 'current' | 'duration' | 'remaining';
@@ -14,7 +15,9 @@ export interface TimeProps {
/** Symbol prepended to remaining time. */
negativeSign?: string | undefined;
/** Custom label for accessibility. */
- label?: string | ((state: TimeState) => string) | undefined;
+ label?: TranslationKeyOrString | ((state: TimeState) => TranslationKeyOrString) | undefined;
+ /** Options for `formatDuration` when building spoken-duration copy (`phrase` state and screen readers), not digital clock text. */
+ formatOptions?: TimeFormatOptions | undefined;
}
export interface TimeState {
@@ -32,20 +35,22 @@ export interface TimeState {
datetime: string;
}
-const DEFAULT_LABELS: Record = {
- current: 'Current time',
- duration: 'Duration',
- remaining: 'Remaining',
+const DEFAULT_LABEL_KEYS: Record = {
+ current: 'timeCurrent',
+ duration: 'timeDuration',
+ remaining: 'timeRemaining',
};
+type TimeCoreResolvedProps = NonNullableObject> & Pick;
+
export class TimeCore {
- static readonly defaultProps: NonNullableObject = {
+ static readonly defaultProps: NonNullableObject> = {
type: 'current',
negativeSign: '-',
label: '',
};
- #props = { ...TimeCore.defaultProps };
+ #props: TimeCoreResolvedProps = { ...TimeCore.defaultProps };
#media: MediaTimeState | null = null;
constructor(props?: TimeProps) {
@@ -53,7 +58,7 @@ export class TimeCore {
}
setProps(props: TimeProps): void {
- this.#props = defaults(props, TimeCore.defaultProps);
+ this.#props = defaults(props, TimeCore.defaultProps) as TimeCoreResolvedProps;
}
setMedia(media: MediaTimeState): void {
@@ -82,15 +87,15 @@ export class TimeCore {
}
#getPhrase(): string {
- const { type } = this.#props;
+ const { type, formatOptions } = this.#props;
const seconds = this.#getSeconds();
if (type === 'remaining') {
// Use negative to trigger "remaining" suffix
- return formatTimeAsPhrase(seconds < 0 ? seconds : -Math.abs(seconds));
+ return formatDuration(seconds < 0 ? seconds : -Math.abs(seconds), formatOptions);
}
- return formatTimeAsPhrase(seconds);
+ return formatDuration(seconds, formatOptions);
}
#getDatetime(): string {
@@ -98,17 +103,11 @@ export class TimeCore {
return secondsToIsoDuration(Math.abs(seconds));
}
- getLabel(state: TimeState): string {
- const { label } = this.#props;
+ getLabel(state: TimeState): TranslationKeyOrString {
+ const custom = resolveOptionalControlLabel(this.#props.label, state);
+ if (custom !== undefined) return custom;
- if (isFunction(label)) {
- const customLabel = label(state);
- if (customLabel) return customLabel;
- } else if (label) {
- return label;
- }
-
- return DEFAULT_LABELS[this.#props.type];
+ return DEFAULT_LABEL_KEYS[this.#props.type];
}
getAttrs(state: TimeState) {
diff --git a/packages/core/src/core/ui/types.ts b/packages/core/src/core/ui/types.ts
index 308bcefb..0dfa7ecb 100644
--- a/packages/core/src/core/ui/types.ts
+++ b/packages/core/src/core/ui/types.ts
@@ -1,5 +1,14 @@
import type { State } from '@videojs/store';
+import type { TranslationKeyOrString } from '../i18n/types';
+
+export type { TranslationKeyOrString };
+
+/** DOM attrs where `aria-label` is a {@link TranslationKeyOrString} resolved by the platform layer. */
+export type TranslationAriaLabelAttrs = {
+ 'aria-label'?: TranslationKeyOrString;
+};
+
export type StateAttrMap = {
[Key in keyof State]?: string;
};
@@ -17,14 +26,14 @@ export interface MediaUIComponent
}
export interface ButtonState {
- label: string;
+ label: TranslationKeyOrString;
}
/** Constraint for media button cores that provide a label derived from state. */
export interface MediaButtonComponent
extends MediaUIComponent {
readonly state: State;
- getLabel(state: ComponentState): string;
+ getLabel(state: ComponentState): TranslationKeyOrString;
}
/** Extracts the media state parameter type from a core's `setMedia` method. */
diff --git a/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts b/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts
index 08403362..cf595d33 100644
--- a/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts
+++ b/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts
@@ -1,3 +1,4 @@
+import { formatVolumePercent } from '@videojs/utils/time';
import { describe, expect, it, vi } from 'vitest';
import type { MediaVolumeState } from '../../../media/state';
@@ -30,7 +31,7 @@ describe('VolumeSliderCore', () => {
describe('defaultProps', () => {
it('has expected defaults', () => {
expect(VolumeSliderCore.defaultProps).toEqual({
- label: 'Volume',
+ label: '',
step: 1,
largeStep: 10,
wheelStep: 5,
@@ -140,8 +141,9 @@ describe('VolumeSliderCore', () => {
const state = core.getState();
const attrs = core.getAttrs(state);
- expect(attrs['aria-label']).toBe('Volume');
- expect(attrs['aria-valuetext']).toBe('75 percent');
+ expect(attrs['aria-label']).toBe('volume');
+ expect(attrs['aria-valuetext']).toBe(formatVolumePercent(0.75));
+ expect(core.getValueTextParams(state)).toEqual({ percent: formatVolumePercent(0.75) });
expect(attrs.role).toBe('slider');
});
@@ -152,7 +154,8 @@ describe('VolumeSliderCore', () => {
const state = core.getState();
const attrs = core.getAttrs(state);
- expect(attrs['aria-valuetext']).toBe('50 percent, muted');
+ expect(attrs['aria-valuetext']).toBe('volumeSliderValueTextMuted');
+ expect(core.getValueTextParams(state)).toEqual({ percent: formatVolumePercent(0.5) });
});
it('rounds value in valuetext', () => {
@@ -162,7 +165,8 @@ describe('VolumeSliderCore', () => {
const state = core.getState();
const attrs = core.getAttrs(state);
- expect(attrs['aria-valuetext']).toBe('33 percent');
+ expect(attrs['aria-valuetext']).toBe(formatVolumePercent(0.333));
+ expect(core.getValueTextParams(state)).toEqual({ percent: formatVolumePercent(0.333) });
});
it('uses custom label', () => {
@@ -182,7 +186,8 @@ describe('VolumeSliderCore', () => {
const state = core.getState();
const attrs = core.getAttrs(state);
- expect(attrs['aria-valuetext']).toBe('0 percent, muted');
+ expect(attrs['aria-valuetext']).toBe('volumeSliderValueTextMuted');
+ expect(core.getValueTextParams(state)).toEqual({ percent: formatVolumePercent(0) });
});
});
diff --git a/packages/core/src/core/ui/volume-slider/volume-slider-core.ts b/packages/core/src/core/ui/volume-slider/volume-slider-core.ts
index 0e77e7b9..f8865233 100644
--- a/packages/core/src/core/ui/volume-slider/volume-slider-core.ts
+++ b/packages/core/src/core/ui/volume-slider/volume-slider-core.ts
@@ -1,8 +1,10 @@
import { defaults } from '@videojs/utils/object';
+import { formatVolumePercent } from '@videojs/utils/time';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaVolumeState } from '../../media/state';
import type { MediaFeatureAvailability } from '../../media/types';
import { SliderCore, type SliderProps, type SliderState } from '../slider/slider-core';
+import type { TranslationKeyOrString } from '../types';
export interface VolumeSliderProps extends SliderProps {
/** Step increment for wheel scrolling. */
@@ -23,11 +25,12 @@ export interface VolumeSliderState extends SliderState, Pick = {
...SliderCore.defaultProps,
- label: 'Volume',
+ label: '',
wheelStep: 5,
};
#media: MediaVolumeState | null = null;
+ #formatLocale: string | string[] | undefined;
constructor(props?: VolumeSliderProps) {
super();
@@ -42,6 +45,11 @@ export class VolumeSliderCore extends SliderCore {
this.#media = media;
}
+ /** @internal Platform adapters set the active i18n locale for `aria-valuetext` percent formatting. */
+ setFormatLocale(locale: string | string[] | undefined): void {
+ this.#formatLocale = locale;
+ }
+
getState(): VolumeSliderState {
const media = this.#media!;
const { volume, muted } = media;
@@ -67,17 +75,24 @@ export class VolumeSliderCore extends SliderCore {
return range > 0 ? (props.wheelStep / range) * 100 : 0;
}
- override getLabel(state: SliderState): string {
- return super.getLabel(state) || 'Volume';
+ override getLabel(state: SliderState): TranslationKeyOrString {
+ return super.getLabel(state) || 'volume';
+ }
+
+ getValueText(state: VolumeSliderState): TranslationKeyOrString {
+ return state.muted ? 'volumeSliderValueTextMuted' : this.getValueTextParams(state).percent;
+ }
+
+ getValueTextParams(state: VolumeSliderState): { percent: string } {
+ return { percent: formatVolumePercent(state.value / 100, this.#formatLocale) };
}
override getAttrs(state: VolumeSliderState) {
const base = super.getAttrs(state);
- const valuetext = `${Math.round(state.value)} percent${state.muted ? ', muted' : ''}`;
return {
...base,
- 'aria-valuetext': valuetext,
+ 'aria-valuetext': this.getValueText(state),
};
}
}
diff --git a/packages/core/src/dom/media/native-hls/errors.ts b/packages/core/src/dom/media/native-hls/errors.ts
index df59e385..f403982b 100644
--- a/packages/core/src/dom/media/native-hls/errors.ts
+++ b/packages/core/src/dom/media/native-hls/errors.ts
@@ -49,7 +49,9 @@ export function NativeHlsMediaErrorsMixin= MediaError.MEDIA_ERR_ABORTED && code <= MediaError.MEDIA_ERR_ENCRYPTED;
+ const error = new MediaError(useCanonicalMessage ? undefined : native.message, code, true);
this.#error = error;
this.dispatchEvent(new ErrorEvent('error', { error, message: error.message }));
diff --git a/packages/core/src/dom/media/native-hls/tests/errors.test.ts b/packages/core/src/dom/media/native-hls/tests/errors.test.ts
index 5b0e4719..6526f8fd 100644
--- a/packages/core/src/dom/media/native-hls/tests/errors.test.ts
+++ b/packages/core/src/dom/media/native-hls/tests/errors.test.ts
@@ -42,7 +42,20 @@ describe('NativeHlsMediaErrorsMixin', () => {
expect(event.error).toBeInstanceOf(MediaError);
expect(event.error.code).toBe(MediaError.MEDIA_ERR_NETWORK);
expect(event.error.fatal).toBe(true);
- expect(event.error.message).toBe('network failure');
+ expect(event.error.message).toBe(MediaError.defaultMessages[MediaError.MEDIA_ERR_NETWORK]);
+ });
+
+ it('normalizes browser-specific messages for standard error codes', () => {
+ const { host, video } = setup();
+
+ const handler = vi.fn();
+ host.addEventListener('error', handler);
+
+ fireNativeError(video, MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED, 'Failed to open media');
+
+ const event = handler.mock.calls[0]![0] as ErrorEvent;
+ expect(event.error.code).toBe(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
+ expect(event.error.message).toBe(MediaError.defaultMessages[MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED]);
});
it('uses default message when native error has no message', () => {
@@ -63,10 +76,11 @@ describe('NativeHlsMediaErrorsMixin', () => {
expect(host.error).toBeNull();
- fireNativeError(video, MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED, 'unsupported');
+ fireNativeError(video, MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED, 'Failed to open media');
expect(host.error).toBeInstanceOf(MediaError);
expect(host.error!.code).toBe(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
+ expect(host.error!.message).toBe(MediaError.defaultMessages[MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED]);
});
it('stops propagation of the native error event', () => {
diff --git a/packages/core/src/dom/media/native-hls/tests/native-hls-custom-media.test.ts b/packages/core/src/dom/media/native-hls/tests/native-hls-custom-media.test.ts
index a38f9c07..49de37de 100644
--- a/packages/core/src/dom/media/native-hls/tests/native-hls-custom-media.test.ts
+++ b/packages/core/src/dom/media/native-hls/tests/native-hls-custom-media.test.ts
@@ -29,6 +29,6 @@ describe('NativeHlsMedia', () => {
expect(event).toBeInstanceOf(ErrorEvent);
expect(event.error).toBeInstanceOf(MediaError);
expect(event.error.code).toBe(MediaError.MEDIA_ERR_NETWORK);
- expect(event.error.message).toBe('network failure');
+ expect(event.error.message).toBe(MediaError.defaultMessages[MediaError.MEDIA_ERR_NETWORK]);
});
});
diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts
index e75118ed..cdba9648 100644
--- a/packages/core/tsdown.config.ts
+++ b/packages/core/tsdown.config.ts
@@ -3,10 +3,16 @@ import { defineConfig } from 'tsdown';
import { type PackageBuildMode, packageBuildConfig, packageBuildModes } from '../../build/tsdown.ts';
import packageJson from './package.json' with { type: 'json' };
+const localeEntries = {
+ 'i18n/locales/en': './src/core/i18n/locales/en.ts',
+};
+
const createConfig = (mode: PackageBuildMode): UserConfig => ({
...packageBuildConfig(mode, 'neutral'),
entry: {
index: './src/core/index.ts',
+ i18n: './src/core/i18n/index.ts',
+ ...localeEntries,
dom: './src/dom/index.ts',
'dom/media/media-host/index': './src/dom/media/media-host.ts',
'dom/media/custom-media-element/index': './src/dom/media/custom-media-element/index.ts',
diff --git a/packages/html/src/ui/playback-rate-radio-group/playback-rate-radio-group-element.ts b/packages/html/src/ui/playback-rate-radio-group/playback-rate-radio-group-element.ts
index dfd6e810..4d46f671 100644
--- a/packages/html/src/ui/playback-rate-radio-group/playback-rate-radio-group-element.ts
+++ b/packages/html/src/ui/playback-rate-radio-group/playback-rate-radio-group-element.ts
@@ -1,5 +1,5 @@
import { PlaybackRateRadioGroupCore, PlaybackRateRadioGroupDataAttrs } from '@videojs/core';
-import { applyStateDataAttrs, logMissingFeature, selectPlaybackRate } from '@videojs/core/dom';
+import { applyElementProps, applyStateDataAttrs, logMissingFeature, selectPlaybackRate } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { playerContext } from '../../player/context';
@@ -54,8 +54,11 @@ export class PlaybackRateRadioGroupElement extends MenuRadioGroupElement {
this.value = this.#core.getRateValue(state.rate);
if (!this.hasAttribute('aria-label') && !this.hasAttribute('aria-labelledby')) {
- this.setAttribute('aria-label', 'Playback rate');
+ this.setAttribute('aria-label', this.#core.getLabel(state));
}
+ applyElementProps(this, {
+ 'aria-disabled': state.disabled ? 'true' : undefined,
+ });
this.#syncContent(state);
}
diff --git a/packages/html/src/ui/playback-rate-radio-group/tests/playback-rate-radio-group-element.test.ts b/packages/html/src/ui/playback-rate-radio-group/tests/playback-rate-radio-group-element.test.ts
index 6d268de2..5cdbe94a 100644
--- a/packages/html/src/ui/playback-rate-radio-group/tests/playback-rate-radio-group-element.test.ts
+++ b/packages/html/src/ui/playback-rate-radio-group/tests/playback-rate-radio-group-element.test.ts
@@ -155,7 +155,7 @@ afterEach(() => {
describe('PlaybackRateRadioGroupElement', () => {
it('renders radio items from the available playback rates', async () => {
- const { menu, trigger } = setup({ playbackRates: [1, 1.25, 1.5], playbackRate: 1.25 });
+ const { menu, options, trigger } = setup({ playbackRates: [1, 1.25, 1.5], playbackRate: 1.25 });
await waitForMenu(menu, trigger);
@@ -165,6 +165,8 @@ describe('PlaybackRateRadioGroupElement', () => {
await waitForAssertion(() => {
expect(items.map((item) => item.getAttribute('aria-checked'))).toEqual(['false', 'true', 'false']);
});
+ expect(options.getAttribute('aria-label')).toBe('playbackRateAria');
+ expect(options.getAttribute('data-rate')).toBe('1.25');
});
it('renders radio items from a template', async () => {
@@ -206,7 +208,7 @@ describe('PlaybackRateButtonElement', () => {
await trigger.updateComplete;
expect(trigger.getAttribute('role')).toBe('button');
- expect(trigger.getAttribute('aria-label')).toBe('Playback rate 2');
+ expect(trigger.getAttribute('aria-label')).toBe('playbackRateAria');
expect(trigger.getAttribute('data-rate')).toBe('2');
});
diff --git a/packages/html/src/ui/time-slider/tests/time-slider-element.test.ts b/packages/html/src/ui/time-slider/tests/time-slider-element.test.ts
index 64aa8977..c247362e 100644
--- a/packages/html/src/ui/time-slider/tests/time-slider-element.test.ts
+++ b/packages/html/src/ui/time-slider/tests/time-slider-element.test.ts
@@ -27,7 +27,7 @@ describe('TimeSliderElement', () => {
it('initializes with default property values', () => {
const slider = createElement(TimeSliderElement);
- expect(slider.label).toBe('Seek');
+ expect(slider.label).toBe('');
expect(slider.changeThrottle).toBe(100);
expect(slider.step).toBe(1);
expect(slider.largeStep).toBe(10);
diff --git a/packages/html/src/ui/time-slider/time-slider-element.ts b/packages/html/src/ui/time-slider/time-slider-element.ts
index 069a64e3..87de0ae9 100644
--- a/packages/html/src/ui/time-slider/time-slider-element.ts
+++ b/packages/html/src/ui/time-slider/time-slider-element.ts
@@ -30,7 +30,7 @@ export class TimeSliderElement extends MediaElement {
orientation: { type: String },
disabled: { type: Boolean },
thumbAlignment: { type: String, attribute: 'thumb-alignment' },
- } satisfies PropertyDeclarationMap>;
+ } satisfies PropertyDeclarationMap>;
label = TimeSliderCore.defaultProps.label;
changeThrottle = TimeSliderCore.defaultProps.changeThrottle;
diff --git a/packages/html/src/ui/time/time-element.ts b/packages/html/src/ui/time/time-element.ts
index 43b7babe..fcfe2a1a 100644
--- a/packages/html/src/ui/time/time-element.ts
+++ b/packages/html/src/ui/time/time-element.ts
@@ -13,7 +13,7 @@ export class TimeElement extends MediaElement {
type: { type: String },
negativeSign: { type: String, attribute: 'negative-sign' },
label: { type: String },
- } satisfies PropertyDeclarationMap;
+ } satisfies PropertyDeclarationMap>;
type: TimeType = TimeCore.defaultProps.type;
negativeSign = TimeCore.defaultProps.negativeSign;
diff --git a/packages/html/src/ui/volume-slider/tests/volume-slider-element.test.ts b/packages/html/src/ui/volume-slider/tests/volume-slider-element.test.ts
index c1eb1a3a..421f673e 100644
--- a/packages/html/src/ui/volume-slider/tests/volume-slider-element.test.ts
+++ b/packages/html/src/ui/volume-slider/tests/volume-slider-element.test.ts
@@ -26,7 +26,7 @@ describe('VolumeSliderElement', () => {
it('initializes with default property values', () => {
const slider = createElement(VolumeSliderElement);
- expect(slider.label).toBe('Volume');
+ expect(slider.label).toBe('');
expect(slider.step).toBe(1);
expect(slider.largeStep).toBe(10);
expect(slider.orientation).toBe('horizontal');
diff --git a/packages/react/src/ui/playback-rate-button/tests/playback-rate-button.test.tsx b/packages/react/src/ui/playback-rate-button/tests/playback-rate-button.test.tsx
index da0a67ff..581d0610 100644
--- a/packages/react/src/ui/playback-rate-button/tests/playback-rate-button.test.tsx
+++ b/packages/react/src/ui/playback-rate-button/tests/playback-rate-button.test.tsx
@@ -49,10 +49,10 @@ describe('PlaybackRateButton', () => {
const button = document.querySelector('[data-testid="button"]');
await waitFor(() => {
- expect(document.querySelector('[data-testid="popup"] span')?.textContent).toBe('Playback rate 1');
+ expect(document.querySelector('[data-testid="popup"] span')?.textContent).toBe('playbackRateAria');
expect(document.querySelector('[data-testid="popup"] kbd')?.textContent).toBe('>');
});
- expect(button?.getAttribute('aria-label')).toBe('Playback rate 1');
+ expect(button?.getAttribute('aria-label')).toBe('playbackRateAria');
expect(button?.getAttribute('aria-keyshortcuts')).toBe('>');
});
});
diff --git a/packages/react/src/ui/playback-rate/tests/use-playback-rate-options.test.tsx b/packages/react/src/ui/playback-rate/tests/use-playback-rate-options.test.tsx
index 01c02e00..71c74b0a 100644
--- a/packages/react/src/ui/playback-rate/tests/use-playback-rate-options.test.tsx
+++ b/packages/react/src/ui/playback-rate/tests/use-playback-rate-options.test.tsx
@@ -72,7 +72,7 @@ describe('usePlaybackRateOptions', () => {
const trigger = screen.getByTestId('trigger');
- expect(trigger.getAttribute('aria-label')).toBe('Playback rate 1.5');
+ expect(trigger.getAttribute('aria-label')).toBe('playbackRateAria');
expect(trigger.getAttribute('data-rate')).toBe('1.5');
});
diff --git a/packages/react/src/ui/time-slider/tests/time-slider.test.tsx b/packages/react/src/ui/time-slider/tests/time-slider.test.tsx
index d16cdfba..0dcd365c 100644
--- a/packages/react/src/ui/time-slider/tests/time-slider.test.tsx
+++ b/packages/react/src/ui/time-slider/tests/time-slider.test.tsx
@@ -178,7 +178,7 @@ describe('TimeSlider compound', () => {
const thumb = container.querySelector('[data-testid="thumb"]');
expect(thumb?.getAttribute('role')).toBe('slider');
- expect(thumb?.getAttribute('aria-label')).toBe('Seek');
+ expect(thumb?.getAttribute('aria-label')).toBe('seek');
});
it('SliderValue displays formatted time', () => {
diff --git a/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx b/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx
index 9972029e..2cfec2e3 100644
--- a/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx
+++ b/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx
@@ -184,7 +184,7 @@ describe('VolumeSlider compound', () => {
const thumb = container.querySelector('[data-testid="thumb"]');
expect(thumb?.getAttribute('role')).toBe('slider');
- expect(thumb?.getAttribute('aria-label')).toBe('Volume');
+ expect(thumb?.getAttribute('aria-label')).toBe('volume');
});
it('SliderValue formats as percentage', () => {
diff --git a/packages/utils/src/dom/effective-locale.ts b/packages/utils/src/dom/effective-locale.ts
new file mode 100644
index 00000000..05ff0f6d
--- /dev/null
+++ b/packages/utils/src/dom/effective-locale.ts
@@ -0,0 +1,16 @@
+import { isUndefined } from '../predicate';
+
+/** Resolves locale: explicit non-empty value → ambient `lang` → {@link fallback}. */
+export function effectiveLocale(
+ explicitLocale: string | undefined,
+ ambientLang: string | undefined,
+ fallback = 'en'
+): string {
+ if (!isUndefined(explicitLocale) && String(explicitLocale).trim() !== '') {
+ return explicitLocale;
+ }
+ if (!isUndefined(ambientLang) && ambientLang.trim() !== '') {
+ return ambientLang;
+ }
+ return fallback;
+}
diff --git a/packages/utils/src/dom/index.ts b/packages/utils/src/dom/index.ts
index 0f4289b4..6fcecdb0 100644
--- a/packages/utils/src/dom/index.ts
+++ b/packages/utils/src/dom/index.ts
@@ -1,6 +1,7 @@
export { animationFrame } from './animation-frame';
export { namedNodeMapToObject, serializeAttributes } from './attributes';
export { isRTL } from './direction';
+export { effectiveLocale } from './effective-locale';
export { type OnEventOptions, onEvent, resolveEventTarget } from './event';
export { idleCallback } from './idle-callback';
export {
@@ -12,6 +13,9 @@ export {
isInteractiveTarget,
} from './interactive';
export { listen } from './listen';
+export { localeFromDomLang } from './locale-from-dom-lang';
+export { mergeLocaleOverlays } from './merge-locale-overlays';
+export { nearestLang } from './nearest-lang';
export { isMacOS } from './platform';
export { tryHidePopover, tryShowPopover } from './popover';
export { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from './predicates';
@@ -25,6 +29,7 @@ export {
} from './shadow-styles';
export { getSlottedElement, querySlot } from './slotted';
export { applyStyles, resolveCSSLength } from './style';
+export { subscribeAmbientLang } from './subscribe-ambient-lang';
export { supportsAnchorPositioning, supportsAnimationFrame, supportsIdleCallback } from './supports';
export { createTemplate, renderTemplate } from './template';
export {
diff --git a/packages/utils/src/dom/locale-from-dom-lang.ts b/packages/utils/src/dom/locale-from-dom-lang.ts
new file mode 100644
index 00000000..d5d3c597
--- /dev/null
+++ b/packages/utils/src/dom/locale-from-dom-lang.ts
@@ -0,0 +1,12 @@
+import { isUndefined } from '../predicate';
+
+/**
+ * Normalizes a raw `lang` string (e.g. from {@link nearestLang}): empty or whitespace-only →
+ * `undefined`, otherwise the trimmed value.
+ */
+export function localeFromDomLang(raw: string | undefined): string | undefined {
+ if (isUndefined(raw) || raw.trim() === '') {
+ return undefined;
+ }
+ return raw.trim();
+}
diff --git a/packages/utils/src/dom/merge-locale-overlays.ts b/packages/utils/src/dom/merge-locale-overlays.ts
new file mode 100644
index 00000000..9c0bd116
--- /dev/null
+++ b/packages/utils/src/dom/merge-locale-overlays.ts
@@ -0,0 +1,27 @@
+/**
+ * Loads overlay layers for each tag in {@link localeLookupChain}, least-specific first, then merges
+ * most-specific-last (same semantics as the core i18n registry).
+ */
+export async function mergeLocaleOverlays(
+ locale: string,
+ load: (tag: string) => Promise | undefined>,
+ localeLookupChain: (locale: string) => string[]
+): Promise<{ merged: Partial; loadedTags: string[] }> {
+ const chain = localeLookupChain(locale);
+ const layers = await Promise.all(chain.map((tag) => load(tag)));
+ const loadedTags: string[] = [];
+ const merged: Partial = {};
+ for (let i = 0; i < chain.length; i++) {
+ const layer = layers[i];
+ if (layer && Object.keys(layer).length > 0) {
+ loadedTags.push(chain[i]!);
+ }
+ }
+ for (let i = chain.length - 1; i >= 0; i--) {
+ const layer = layers[i];
+ if (layer) {
+ Object.assign(merged, layer);
+ }
+ }
+ return { merged, loadedTags };
+}
diff --git a/packages/utils/src/dom/nearest-lang.ts b/packages/utils/src/dom/nearest-lang.ts
new file mode 100644
index 00000000..10ee34ad
--- /dev/null
+++ b/packages/utils/src/dom/nearest-lang.ts
@@ -0,0 +1,29 @@
+function readLang(node: Element): string | undefined {
+ const fromAttribute = node.getAttribute('lang')?.trim();
+ if (fromAttribute) {
+ return fromAttribute;
+ }
+ if ('lang' in node && typeof node.lang === 'string') {
+ const fromProperty = node.lang.trim();
+ if (fromProperty) {
+ return fromProperty;
+ }
+ }
+ return undefined;
+}
+
+/** First non-empty `lang` on `start` or an ancestor (HTML language inheritance). */
+export function nearestLang(start: Element | null): string | undefined {
+ if (!start || typeof document === 'undefined') {
+ return undefined;
+ }
+ let node: Element | null = start;
+ while (node) {
+ const lang = readLang(node);
+ if (lang) {
+ return lang;
+ }
+ node = node.parentElement;
+ }
+ return undefined;
+}
diff --git a/packages/utils/src/dom/subscribe-ambient-lang.ts b/packages/utils/src/dom/subscribe-ambient-lang.ts
new file mode 100644
index 00000000..aadbf31d
--- /dev/null
+++ b/packages/utils/src/dom/subscribe-ambient-lang.ts
@@ -0,0 +1,37 @@
+/**
+ * Subscribes to DOM updates that can change inherited `lang`: any `lang` attribute edit,
+ * or subtree structural changes under `` (which can move nodes between labeled ancestors).
+ */
+export function subscribeAmbientLang(onStoreChange: () => void): () => void {
+ if (typeof document === 'undefined') {
+ return () => {};
+ }
+ let disconnected = false;
+ let queued = false;
+ const flush = (): void => {
+ queued = false;
+ if (disconnected) {
+ return;
+ }
+ onStoreChange();
+ };
+ const schedule = (): void => {
+ if (!queued) {
+ queued = true;
+ queueMicrotask(flush);
+ }
+ };
+ const root = document.documentElement;
+ const observer = new MutationObserver(schedule);
+ observer.observe(root, {
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['lang'],
+ childList: true,
+ });
+ return () => {
+ disconnected = true;
+ observer.disconnect();
+ queued = false;
+ };
+}
diff --git a/packages/utils/src/dom/tests/effective-locale.test.ts b/packages/utils/src/dom/tests/effective-locale.test.ts
new file mode 100644
index 00000000..77e24417
--- /dev/null
+++ b/packages/utils/src/dom/tests/effective-locale.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+
+import { effectiveLocale } from '../effective-locale';
+
+describe('effectiveLocale', () => {
+ it('prefers explicit locale over ambient', () => {
+ expect(effectiveLocale('fr', 'de')).toBe('fr');
+ });
+
+ it('uses ambient when explicit is undefined', () => {
+ expect(effectiveLocale(undefined, 'es-MX')).toBe('es-MX');
+ });
+
+ it('falls back to en when both missing or blank', () => {
+ expect(effectiveLocale(undefined, undefined)).toBe('en');
+ expect(effectiveLocale(' ', undefined)).toBe('en');
+ expect(effectiveLocale(undefined, ' ')).toBe('en');
+ });
+
+ it('respects custom fallback', () => {
+ expect(effectiveLocale(undefined, undefined, 'xx')).toBe('xx');
+ });
+});
diff --git a/packages/utils/src/dom/tests/locale-from-dom-lang.test.ts b/packages/utils/src/dom/tests/locale-from-dom-lang.test.ts
new file mode 100644
index 00000000..00a56d2c
--- /dev/null
+++ b/packages/utils/src/dom/tests/locale-from-dom-lang.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+
+import { localeFromDomLang } from '../locale-from-dom-lang';
+
+describe('localeFromDomLang', () => {
+ it('returns undefined for missing or blank', () => {
+ expect(localeFromDomLang(undefined)).toBeUndefined();
+ expect(localeFromDomLang('')).toBeUndefined();
+ expect(localeFromDomLang(' ')).toBeUndefined();
+ });
+
+ it('returns trimmed language tag', () => {
+ expect(localeFromDomLang(' fr ')).toBe('fr');
+ expect(localeFromDomLang('de-DE')).toBe('de-DE');
+ });
+});
diff --git a/packages/utils/src/dom/tests/merge-locale-overlays.test.ts b/packages/utils/src/dom/tests/merge-locale-overlays.test.ts
new file mode 100644
index 00000000..4f1ca367
--- /dev/null
+++ b/packages/utils/src/dom/tests/merge-locale-overlays.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+
+import { mergeLocaleOverlays } from '../merge-locale-overlays';
+
+describe('mergeLocaleOverlays', () => {
+ it('merges layers least-specific to most-specific', async () => {
+ const chain = (_locale: string) => ['es', 'en'];
+ const load = async (tag: string): Promise> | undefined> =>
+ tag === 'en' ? { a: 'en-a', b: 'en-b' } : tag === 'es' ? { b: 'es-b', c: 'es-c' } : undefined;
+
+ const { merged, loadedTags } = await mergeLocaleOverlays('es', load, chain);
+ expect(merged).toEqual({ a: 'en-a', b: 'es-b', c: 'es-c' });
+ expect(loadedTags).toEqual(['es', 'en']);
+ });
+
+ it('skips undefined layers', async () => {
+ const chain = () => ['xx', 'en'];
+ const load = async (tag: string) => (tag === 'en' ? { k: 'v' } : undefined);
+ const { merged, loadedTags } = await mergeLocaleOverlays('xx', load, chain);
+ expect(merged).toEqual({ k: 'v' });
+ expect(loadedTags).toEqual(['en']);
+ });
+});
diff --git a/packages/utils/src/dom/tests/nearest-lang.test.ts b/packages/utils/src/dom/tests/nearest-lang.test.ts
new file mode 100644
index 00000000..d4724ea4
--- /dev/null
+++ b/packages/utils/src/dom/tests/nearest-lang.test.ts
@@ -0,0 +1,57 @@
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { nearestLang } from '../nearest-lang';
+
+describe('nearestLang', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ document.documentElement.removeAttribute('lang');
+ });
+
+ it('returns undefined for null start', () => {
+ expect(nearestLang(null)).toBeUndefined();
+ });
+
+ it('reads lang on the start element', () => {
+ const el = document.createElement('div');
+ el.setAttribute('lang', 'fr');
+ document.body.appendChild(el);
+ expect(nearestLang(el)).toBe('fr');
+ });
+
+ it('walks ancestors and prefers closest lang', () => {
+ const outer = document.createElement('section');
+ outer.setAttribute('lang', 'de');
+ const inner = document.createElement('div');
+ inner.setAttribute('lang', 'fr');
+ outer.appendChild(inner);
+ document.body.appendChild(outer);
+ expect(nearestLang(inner)).toBe('fr');
+ });
+
+ it('inherits from an ancestor when start has no lang', () => {
+ const outer = document.createElement('section');
+ outer.setAttribute('lang', 'de');
+ const inner = document.createElement('div');
+ outer.appendChild(inner);
+ document.body.appendChild(outer);
+ expect(nearestLang(inner)).toBe('de');
+ });
+
+ it('ignores empty lang and continues walking', () => {
+ const outer = document.createElement('section');
+ outer.setAttribute('lang', 'de');
+ const inner = document.createElement('div');
+ inner.setAttribute('lang', ' ');
+ outer.appendChild(inner);
+ document.body.appendChild(outer);
+ expect(nearestLang(inner)).toBe('de');
+ });
+
+ it('reads lang IDL property on html when set via documentElement.lang', () => {
+ document.documentElement.lang = 'fr';
+ const inner = document.createElement('div');
+ document.body.appendChild(inner);
+ expect(nearestLang(inner)).toBe('fr');
+ });
+});
diff --git a/packages/utils/src/dom/tests/subscribe-ambient-lang.test.ts b/packages/utils/src/dom/tests/subscribe-ambient-lang.test.ts
new file mode 100644
index 00000000..0aae1f68
--- /dev/null
+++ b/packages/utils/src/dom/tests/subscribe-ambient-lang.test.ts
@@ -0,0 +1,41 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { subscribeAmbientLang } from '../subscribe-ambient-lang';
+
+describe('subscribeAmbientLang', () => {
+ afterEach(() => {
+ document.documentElement.removeAttribute('lang');
+ vi.restoreAllMocks();
+ });
+
+ it('invokes callback when html lang changes', async () => {
+ const spy = vi.fn();
+ const off = subscribeAmbientLang(spy);
+ document.documentElement.setAttribute('lang', 'de');
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(spy).toHaveBeenCalled();
+ off();
+ });
+
+ it('invokes callback when html lang property changes', async () => {
+ const spy = vi.fn();
+ const off = subscribeAmbientLang(spy);
+ document.documentElement.lang = 'fr';
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(spy).toHaveBeenCalled();
+ off();
+ });
+
+ it('unsubscribe stops notifications', async () => {
+ const spy = vi.fn();
+ const off = subscribeAmbientLang(spy);
+ off();
+ spy.mockClear();
+ document.documentElement.setAttribute('lang', 'fr');
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(spy).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/utils/src/time/format.ts b/packages/utils/src/time/format.ts
index 849abc7d..5cf829f5 100644
--- a/packages/utils/src/time/format.ts
+++ b/packages/utils/src/time/format.ts
@@ -1,11 +1,79 @@
import { isNumber } from '../predicate/predicate';
+export type TimeFormatOptions = {
+ /** BCP 47 tag(s) for {@link Intl.DurationFormat} (and percent formatting where applicable). */
+ locale?: string | string[];
+ /** Called only when `seconds` is negative; formats the localized remaining-time phrase for the duration body. */
+ formatRemaining?: (duration: string) => string;
+ /** Passed to `Intl.DurationFormat`; defaults to `"long"`. */
+ style?: 'long' | 'short' | 'narrow' | 'digital';
+};
+
const UNIT_LABELS = [
{ singular: 'hour', plural: 'hours' },
{ singular: 'minute', plural: 'minutes' },
{ singular: 'second', plural: 'seconds' },
] as const;
+type DurationFormatConstructor = new (
+ locales?: string | string[],
+ options?: { style?: TimeFormatOptions['style'] }
+) => { format: (duration: object) => string };
+
+const DurationFormat = (Intl as typeof Intl & { DurationFormat?: DurationFormatConstructor }).DurationFormat;
+
+const percentFormatters = new Map();
+const durationFormatters = new Map>>();
+
+function localeCacheKey(locale?: string | string[]): string {
+ if (locale === undefined) return '';
+ return Array.isArray(locale) ? locale.join('\0') : locale;
+}
+
+function isEnglishLocale(locale?: string | string[]): boolean {
+ const tag = Array.isArray(locale) ? locale[0] : locale;
+ if (!tag) return true;
+ return tag === 'en' || tag.startsWith('en-');
+}
+
+function getPercentFormatter(locale?: string | string[]): Intl.NumberFormat | undefined {
+ const key = localeCacheKey(locale);
+ let formatter = percentFormatters.get(key);
+ if (!formatter) {
+ try {
+ formatter = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 });
+ percentFormatters.set(key, formatter);
+ } catch {
+ return undefined;
+ }
+ }
+ return formatter;
+}
+
+function formatVolumePercentFallback(fraction: number): string {
+ const percent = Math.round(Math.min(1, Math.max(0, fraction)) * 100);
+ return `${percent}%`;
+}
+
+function getDurationFormatter(
+ locale?: string | string[],
+ style: NonNullable = 'long'
+): InstanceType> | undefined {
+ if (!DurationFormat) return undefined;
+
+ const key = `${localeCacheKey(locale)}\0${style}`;
+ let formatter = durationFormatters.get(key);
+ if (!formatter) {
+ try {
+ formatter = new DurationFormat(locale, { style });
+ durationFormatters.set(key, formatter);
+ } catch {
+ return undefined;
+ }
+ }
+ return formatter;
+}
+
function isValidTime(value: number): boolean {
return isNumber(value) && Number.isFinite(value);
}
@@ -118,3 +186,69 @@ export function secondsToIsoDuration(seconds: number): string {
return duration;
}
+
+/**
+ * Human-readable duration using {@link Intl.DurationFormat} when available.
+ *
+ * Negative `seconds` denote remaining time: the absolute value is formatted, then wrapped in a
+ * localized phrase via {@link TimeFormatOptions.formatRemaining}; otherwise `{duration} remaining`.
+ */
+export function formatDuration(seconds: number, options?: TimeFormatOptions): string {
+ if (!isValidTime(seconds)) {
+ return '';
+ }
+
+ const negative = seconds < 0;
+ const positiveSeconds = Math.abs(seconds);
+ const totalSeconds = Math.floor(positiveSeconds);
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const secondsPart = totalSeconds % 60;
+
+ const record: Partial<{ hours: number; minutes: number; seconds: number }> = {};
+ if (hours > 0) record.hours = hours;
+ if (minutes > 0) record.minutes = minutes;
+ if (secondsPart > 0 || (hours === 0 && minutes === 0)) record.seconds = secondsPart;
+
+ let body: string;
+ try {
+ const durationFormatter = getDurationFormatter(options?.locale, options?.style ?? 'long');
+ if (durationFormatter) {
+ body = durationFormatter.format(record);
+ } else {
+ body = formatTimeAsPhrase(positiveSeconds);
+ }
+ } catch {
+ body = formatTimeAsPhrase(positiveSeconds);
+ }
+
+ // Some ICU builds return an empty string for a zero-length duration; fall back to the phrase formatter.
+ if (!body.trim()) {
+ body = formatTimeAsPhrase(positiveSeconds);
+ }
+
+ if (negative) {
+ const formatRemaining = options?.formatRemaining;
+ if (formatRemaining) return formatRemaining(body);
+ if (isEnglishLocale(options?.locale)) return `${body} remaining`;
+ return body;
+ }
+
+ return body;
+}
+
+/** Format a volume fraction (0–1) with {@link Intl.NumberFormat} `style: "percent"`. */
+export function formatVolumePercent(fraction: number, locale?: string | string[]): string {
+ const value = !isNumber(fraction) || !Number.isFinite(fraction) ? 0 : Math.min(1, Math.max(0, fraction));
+
+ try {
+ const formatter = getPercentFormatter(locale) ?? getPercentFormatter(undefined);
+ if (formatter) {
+ return formatter.format(value);
+ }
+ } catch {
+ // fall through to simple percent string
+ }
+
+ return formatVolumePercentFallback(value);
+}
diff --git a/packages/utils/src/time/tests/format.test.ts b/packages/utils/src/time/tests/format.test.ts
index a05077f9..040b1a54 100644
--- a/packages/utils/src/time/tests/format.test.ts
+++ b/packages/utils/src/time/tests/format.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { formatTime, formatTimeAsPhrase, secondsToIsoDuration } from '../format';
+import { formatDuration, formatTime, formatTimeAsPhrase, formatVolumePercent, secondsToIsoDuration } from '../format';
describe('formatTime', () => {
it('formats seconds only', () => {
@@ -99,6 +99,80 @@ describe('formatTimeAsPhrase', () => {
});
});
+describe('formatDuration', () => {
+ it('formats positive duration', () => {
+ expect(formatDuration(90)).toContain('1');
+ expect(formatDuration(90)).toMatch(/minute/i);
+ expect(formatDuration(90)).toMatch(/30/);
+ expect(formatDuration(300)).toMatch(/5/);
+ expect(formatDuration(300)).toMatch(/minute/i);
+ });
+
+ it('adds remaining suffix for negative seconds', () => {
+ expect(formatDuration(-30)).toMatch(/30/);
+ expect(formatDuration(-30)).toMatch(/remaining$/i);
+ });
+
+ it('uses formatRemaining only for negative durations', () => {
+ expect(formatDuration(-30, { formatRemaining: (duration) => `quedan ${duration}` })).toMatch(/^quedan /);
+ expect(formatDuration(-30, { formatRemaining: (duration) => `quedan ${duration}` })).toMatch(/30/);
+ expect(formatDuration(90, { formatRemaining: () => 'should-not-appear' })).toBe(formatDuration(90));
+ });
+
+ it('omits English remaining suffix for non-English locales without formatRemaining', () => {
+ const formatted = formatDuration(-30, { locale: 'es' });
+ expect(formatted).toMatch(/30/);
+ expect(formatted).not.toMatch(/remaining$/i);
+ });
+
+ it('uses Intl.DurationFormat when supported; otherwise matches formatTimeAsPhrase', () => {
+ const DurationFormatConstructor = (Intl as typeof Intl & { DurationFormat?: unknown }).DurationFormat;
+ const hasDurationFormat = typeof DurationFormatConstructor === 'function';
+ const phrase = formatTimeAsPhrase(125);
+ if (hasDurationFormat) {
+ const en = formatDuration(125, { locale: 'en' });
+ const de = formatDuration(125, { locale: 'de' });
+ expect(en.length).toBeGreaterThan(0);
+ expect(de.length).toBeGreaterThan(0);
+ expect(en).not.toBe(de);
+ } else {
+ expect(formatDuration(125, { locale: 'en' })).toBe(phrase);
+ expect(formatDuration(125, { locale: 'ja' })).toBe(phrase);
+ }
+ });
+
+ it('handles invalid values', () => {
+ expect(formatDuration(NaN)).toBe('');
+ expect(formatDuration(Infinity)).toBe('');
+ });
+
+ it('falls back to formatTimeAsPhrase when locale is invalid', () => {
+ const phrase = formatTimeAsPhrase(90);
+ expect(formatDuration(90, { locale: 'not-a-valid-bcp47-tag!!!' })).toBe(phrase);
+ });
+});
+
+describe('formatVolumePercent', () => {
+ it('uses Intl percent style', () => {
+ expect(formatVolumePercent(0.75)).toMatch(/75/);
+ expect(formatVolumePercent(0.75)).toMatch(/%/);
+ });
+
+ it('clamps to 0–100%', () => {
+ expect(formatVolumePercent(-1)).toBe(formatVolumePercent(0));
+ expect(formatVolumePercent(2)).toBe(formatVolumePercent(1));
+ });
+
+ it('handles invalid fraction', () => {
+ expect(formatVolumePercent(Number.NaN)).toMatch(/0/);
+ expect(formatVolumePercent(Number.NaN)).toMatch(/%/);
+ });
+
+ it('falls back when locale is invalid', () => {
+ expect(formatVolumePercent(0.75, 'not-a-invalid-bcp47-tag!!!')).toBe('75%');
+ });
+});
+
describe('secondsToIsoDuration', () => {
it('formats seconds only', () => {
expect(secondsToIsoDuration(0)).toBe('PT0S');
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 22f4c3b1..88b2b1ec 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4951,9 +4951,6 @@ packages:
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
engines: {node: '>= 0.4'}
- get-tsconfig@4.13.6:
- resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
-
get-tsconfig@4.14.0:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
@@ -13113,10 +13110,6 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
- get-tsconfig@4.13.6:
- dependencies:
- resolve-pkg-maps: 1.0.0
-
get-tsconfig@4.14.0:
dependencies:
resolve-pkg-maps: 1.0.0
@@ -16089,7 +16082,7 @@ snapshots:
tsx@4.21.0:
dependencies:
esbuild: 0.27.3
- get-tsconfig: 4.13.6
+ get-tsconfig: 4.14.0
optionalDependencies:
fsevents: 2.3.3