From 768bf09da07a728874da232c7cdefb653534e078 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Fri, 19 Jun 2026 04:55:38 +1000 Subject: [PATCH] feat(core): add i18n foundation with English locale and UI wiring (#1589) Co-authored-by: Cursor Co-authored-by: Wesley Luyten --- internal/design/i18n.md | 329 ++++++++---------- packages/core/package.json | 14 +- .../core/src/core/i18n/browser-translation.ts | 168 +++++++++ packages/core/src/core/i18n/index.ts | 19 + packages/core/src/core/i18n/locales/en.ts | 55 +++ packages/core/src/core/i18n/registry.ts | 107 ++++++ .../core/i18n/resolve-translation-phrase.ts | 11 + .../i18n/tests/browser-translation.test.ts | 196 +++++++++++ .../core/src/core/i18n/tests/registry.test.ts | 93 +++++ .../src/core/i18n/tests/translator.test.ts | 30 ++ packages/core/src/core/i18n/translator.ts | 28 ++ packages/core/src/core/i18n/types.ts | 91 +++++ packages/core/src/core/index.ts | 4 + .../captions-button/captions-button-core.ts | 20 +- .../tests/captions-button-core.test.ts | 6 +- .../core/ui/cast-button/cast-button-core.ts | 24 +- .../tests/cast-button-core.test.ts | 8 +- .../core/ui/error-dialog/error-dialog-i18n.ts | 78 +++++ .../tests/error-dialog-i18n.test.ts | 54 +++ .../fullscreen-button-core.ts | 20 +- .../tests/fullscreen-button-core.test.ts | 6 +- .../core/src/core/ui/input-feedback/labels.ts | 19 + .../core/src/core/ui/input-feedback/status.ts | 4 +- .../ui/input-feedback/tests/labels.test.ts | 32 ++ .../input-feedback/volume-indicator-core.ts | 12 +- .../core/ui/live-button/live-button-core.ts | 22 +- .../tests/live-button-core.test.ts | 4 +- .../core/ui/mute-button/mute-button-core.ts | 20 +- .../tests/mute-button-core.test.ts | 10 +- .../src/core/ui/pip-button/pip-button-core.ts | 20 +- .../pip-button/tests/pip-button-core.test.ts | 6 +- .../core/ui/play-button/play-button-core.ts | 22 +- .../tests/play-button-core.test.ts | 16 +- .../playback-rate-button-core.ts | 25 +- .../tests/playback-rate-button-core.test.ts | 20 +- .../playback-rate-radio-group-core.ts | 27 +- .../playback-rate-radio-group-core.test.ts | 16 +- .../core/src/core/ui/resolve-control-attrs.ts | 58 +++ .../core/ui/resolve-optional-control-label.ts | 46 +++ .../core/ui/seek-button/seek-button-core.ts | 26 +- .../tests/seek-button-core.test.ts | 43 ++- .../core/src/core/ui/slider/slider-core.ts | 18 +- .../ui/tests/resolve-control-attrs.test.ts | 36 ++ .../resolve-optional-control-label.test.ts | 60 ++++ .../tests/time-slider-core.test.ts | 24 +- .../core/ui/time-slider/time-slider-core.ts | 44 ++- .../src/core/ui/time/tests/time-core.test.ts | 35 +- packages/core/src/core/ui/time/time-core.ts | 45 ++- packages/core/src/core/ui/types.ts | 13 +- .../tests/volume-slider-core.test.ts | 17 +- .../ui/volume-slider/volume-slider-core.ts | 25 +- .../core/src/dom/media/native-hls/errors.ts | 4 +- .../dom/media/native-hls/tests/errors.test.ts | 18 +- .../tests/native-hls-custom-media.test.ts | 2 +- packages/core/tsdown.config.ts | 6 + .../playback-rate-radio-group-element.ts | 7 +- .../playback-rate-radio-group-element.test.ts | 6 +- .../tests/time-slider-element.test.ts | 2 +- .../src/ui/time-slider/time-slider-element.ts | 2 +- packages/html/src/ui/time/time-element.ts | 2 +- .../tests/volume-slider-element.test.ts | 2 +- .../tests/playback-rate-button.test.tsx | 4 +- .../tests/use-playback-rate-options.test.tsx | 2 +- .../ui/time-slider/tests/time-slider.test.tsx | 2 +- .../tests/volume-slider.test.tsx | 2 +- packages/utils/src/dom/effective-locale.ts | 16 + packages/utils/src/dom/index.ts | 5 + .../utils/src/dom/locale-from-dom-lang.ts | 12 + .../utils/src/dom/merge-locale-overlays.ts | 27 ++ packages/utils/src/dom/nearest-lang.ts | 29 ++ .../utils/src/dom/subscribe-ambient-lang.ts | 37 ++ .../src/dom/tests/effective-locale.test.ts | 23 ++ .../dom/tests/locale-from-dom-lang.test.ts | 16 + .../dom/tests/merge-locale-overlays.test.ts | 23 ++ .../utils/src/dom/tests/nearest-lang.test.ts | 57 +++ .../dom/tests/subscribe-ambient-lang.test.ts | 41 +++ packages/utils/src/time/format.ts | 134 +++++++ packages/utils/src/time/tests/format.test.ts | 76 +++- pnpm-lock.yaml | 9 +- 79 files changed, 2237 insertions(+), 455 deletions(-) create mode 100644 packages/core/src/core/i18n/browser-translation.ts create mode 100644 packages/core/src/core/i18n/index.ts create mode 100644 packages/core/src/core/i18n/locales/en.ts create mode 100644 packages/core/src/core/i18n/registry.ts create mode 100644 packages/core/src/core/i18n/resolve-translation-phrase.ts create mode 100644 packages/core/src/core/i18n/tests/browser-translation.test.ts create mode 100644 packages/core/src/core/i18n/tests/registry.test.ts create mode 100644 packages/core/src/core/i18n/tests/translator.test.ts create mode 100644 packages/core/src/core/i18n/translator.ts create mode 100644 packages/core/src/core/i18n/types.ts create mode 100644 packages/core/src/core/ui/error-dialog/error-dialog-i18n.ts create mode 100644 packages/core/src/core/ui/error-dialog/tests/error-dialog-i18n.test.ts create mode 100644 packages/core/src/core/ui/input-feedback/labels.ts create mode 100644 packages/core/src/core/ui/input-feedback/tests/labels.test.ts create mode 100644 packages/core/src/core/ui/resolve-control-attrs.ts create mode 100644 packages/core/src/core/ui/resolve-optional-control-label.ts create mode 100644 packages/core/src/core/ui/tests/resolve-control-attrs.test.ts create mode 100644 packages/core/src/core/ui/tests/resolve-optional-control-label.test.ts create mode 100644 packages/utils/src/dom/effective-locale.ts create mode 100644 packages/utils/src/dom/locale-from-dom-lang.ts create mode 100644 packages/utils/src/dom/merge-locale-overlays.ts create mode 100644 packages/utils/src/dom/nearest-lang.ts create mode 100644 packages/utils/src/dom/subscribe-ambient-lang.ts create mode 100644 packages/utils/src/dom/tests/effective-locale.test.ts create mode 100644 packages/utils/src/dom/tests/locale-from-dom-lang.test.ts create mode 100644 packages/utils/src/dom/tests/merge-locale-overlays.test.ts create mode 100644 packages/utils/src/dom/tests/nearest-lang.test.ts create mode 100644 packages/utils/src/dom/tests/subscribe-ambient-lang.test.ts 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