Skip to content

Commit 539fecd

Browse files
sampottscursoragent
andcommitted
feat(packages): add html i18n layer and shared dom locale helpers
Centralize ambient lang, locale lookup chain merge, and effective locale in @videojs/utils/dom so React and the new Lit provider reuse the same behavior. Wire @videojs/html i18n subpath exports and wildcard locale packs on core/react. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b592706 commit 539fecd

23 files changed

Lines changed: 771 additions & 89 deletions

packages/core/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@
3939
"development": "./dist/dev/i18n.js",
4040
"default": "./dist/default/i18n.js"
4141
},
42-
"./i18n/locales/en": {
43-
"types": "./dist/dev/i18n/locales/en.d.ts",
44-
"development": "./dist/dev/i18n/locales/en.js",
45-
"default": "./dist/default/i18n/locales/en.js"
42+
"./i18n/locales/*": {
43+
"types": "./dist/dev/i18n/locales/*.d.ts",
44+
"development": "./dist/dev/i18n/locales/*.js",
45+
"default": "./dist/default/i18n/locales/*.js"
4646
}
4747
},
4848
"main": "dist/default/index.js",

packages/html/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"types": "dist/dev/index.d.ts",
1515
"sideEffects": [
1616
"./dist/*/define/**/*.js",
17+
"./dist/*/i18n/define-elements.js",
1718
"./dist/*/icons/element/**/*.js",
1819
"./dist/*/icons/dist/element/**/*.js",
1920
"./cdn/**/*.js"
@@ -118,6 +119,16 @@
118119
"development": "./cdn/*.dev.js",
119120
"default": "./cdn/*.js"
120121
},
122+
"./i18n": {
123+
"types": "./dist/dev/i18n/index.d.ts",
124+
"development": "./dist/dev/i18n/index.js",
125+
"default": "./dist/default/i18n/index.js"
126+
},
127+
"./i18n/locales/*": {
128+
"types": "./dist/dev/i18n/locales/*.d.ts",
129+
"development": "./dist/dev/i18n/locales/*.js",
130+
"default": "./dist/default/i18n/locales/*.js"
131+
},
121132
"./*.css": "./dist/default/define/*.css",
122133
"./*": {
123134
"types": "./dist/dev/define/*.d.ts",
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import {
2+
createTranslator,
3+
getI18nTranslations,
4+
type Locale,
5+
localeLookupChain,
6+
onI18nRegistryChange,
7+
type Translations,
8+
type Translator,
9+
} from '@videojs/core/i18n';
10+
import type { PropertyValues, ReactiveController, ReactiveControllerHost, ReactiveElement } from '@videojs/element';
11+
import { type Context, ContextConsumer, ContextProvider, createContext } from '@videojs/element/context';
12+
import { mergeLocaleOverlays, subscribeAmbientLang } from '@videojs/utils/dom';
13+
import type { Constructor } from '@videojs/utils/types';
14+
15+
import { playerContext } from '../player/context';
16+
import { resolveProviderLocale } from './locale';
17+
import { selectCaptionsByLocale } from './select-captions-by-locale';
18+
19+
async function noopBuiltinPack(_tag: string): Promise<Partial<Translations> | undefined> {
20+
return undefined;
21+
}
22+
23+
/** Reflected i18n keys are untyped strings; the runtime translator accepts any key. */
24+
function translateReflectedKey(translator: Translator, key: string): string {
25+
const translateLoose = translator as (k: string, params?: unknown) => string;
26+
return translateLoose(key);
27+
}
28+
29+
export interface I18nContextValue {
30+
translator: Translator;
31+
locale: Locale;
32+
}
33+
34+
export interface CreateI18nOptions {
35+
loadBuiltinLocale?: (tag: string) => Promise<Partial<Translations> | undefined>;
36+
}
37+
38+
/** Per-factory context identity (see {@link createI18n}). */
39+
export type I18nLitContext = Context<symbol, I18nContextValue>;
40+
41+
/**
42+
* `Constructor<ReactiveElement>` does not imply static `properties`; this intersection matches how
43+
* mixins spread {@link ReactiveElement.properties} from their base.
44+
*/
45+
type ReactiveElementMixinBase = Constructor<ReactiveElement> & Pick<typeof ReactiveElement, 'properties'>;
46+
47+
export interface CreateI18nResult {
48+
context: I18nLitContext;
49+
I18nController: new (
50+
host: ReactiveControllerHost & HTMLElement
51+
) => ReactiveController & {
52+
readonly value: Translator;
53+
readonly locale: Locale;
54+
};
55+
ProviderMixin: <Base extends ReactiveElementMixinBase>(Base: Base) => Constructor<ReactiveElement> & Base;
56+
TextMixin: <Base extends ReactiveElementMixinBase>(Base: Base) => Constructor<ReactiveElement> & Base;
57+
}
58+
59+
export function createI18n(options?: CreateI18nOptions): CreateI18nResult {
60+
const loadBuiltin = options?.loadBuiltinLocale ?? noopBuiltinPack;
61+
const fallbackTranslator = createTranslator(getI18nTranslations('en'), 'en');
62+
63+
const i18nContextKey = Symbol('@videojs/i18n');
64+
const i18nContext = createContext<I18nContextValue, typeof i18nContextKey>(i18nContextKey);
65+
66+
class I18nControllerImpl implements ReactiveController {
67+
readonly #host: ReactiveControllerHost & HTMLElement;
68+
readonly #consumer: ContextConsumer<I18nLitContext, ReactiveControllerHost & HTMLElement>;
69+
70+
constructor(host: ReactiveControllerHost & HTMLElement) {
71+
this.#host = host;
72+
this.#consumer = new ContextConsumer(host, {
73+
context: i18nContext,
74+
callback: () => this.#host.requestUpdate(),
75+
subscribe: true,
76+
});
77+
host.addController(this);
78+
}
79+
80+
get value(): Translator {
81+
return this.#consumer.value?.translator ?? fallbackTranslator;
82+
}
83+
84+
get locale(): Locale {
85+
return this.#consumer.value?.locale ?? 'en';
86+
}
87+
88+
hostConnected(): void {}
89+
90+
hostDisconnected(): void {}
91+
}
92+
93+
const ProviderMixin = <Base extends ReactiveElementMixinBase>(Base: Base) => {
94+
class I18nProviderElement extends Base {
95+
static properties = {
96+
...Base.properties,
97+
lang: { type: String, reflect: true },
98+
};
99+
100+
lang = '';
101+
102+
readonly #i18nProvider = new ContextProvider(this, {
103+
context: i18nContext,
104+
initialValue: {
105+
translator: fallbackTranslator,
106+
locale: 'en',
107+
},
108+
});
109+
110+
#registryUnsub: (() => void) | undefined;
111+
#ambientUnsub: (() => void) | undefined;
112+
#lazyLayer: Partial<Translations> = {};
113+
#lazySeq = 0;
114+
/** Tracks locale used for `#lazyLayer`; ambient `lang` can change without the `lang` property. */
115+
#resolvedLocaleForLazy: Locale | undefined;
116+
117+
readonly #storeConsumer = new ContextConsumer(this, {
118+
context: playerContext,
119+
callback: () => this.#syncCaptions(),
120+
subscribe: true,
121+
});
122+
123+
override connectedCallback(): void {
124+
super.connectedCallback();
125+
this.#registryUnsub = onI18nRegistryChange(() => this.requestUpdate());
126+
this.#ambientUnsub = subscribeAmbientLang(() => this.requestUpdate());
127+
this.#resetLazyAndLoad();
128+
this.requestUpdate();
129+
}
130+
131+
override disconnectedCallback(): void {
132+
super.disconnectedCallback();
133+
this.#registryUnsub?.();
134+
this.#registryUnsub = undefined;
135+
this.#ambientUnsub?.();
136+
this.#ambientUnsub = undefined;
137+
this.#lazySeq += 1;
138+
this.#lazyLayer = {};
139+
this.#resolvedLocaleForLazy = undefined;
140+
}
141+
142+
protected override willUpdate(changed: PropertyValues): void {
143+
super.willUpdate(changed);
144+
const locale = resolveProviderLocale(this);
145+
if (this.#resolvedLocaleForLazy !== locale) {
146+
const hadLocale = this.#resolvedLocaleForLazy !== undefined;
147+
this.#resolvedLocaleForLazy = locale;
148+
if (hadLocale && this.hasUpdated) {
149+
this.#resetLazyAndLoad();
150+
}
151+
}
152+
}
153+
154+
protected override updated(changed: PropertyValues): void {
155+
super.updated(changed);
156+
this.#publish();
157+
this.#syncCaptions();
158+
}
159+
160+
#resetLazyAndLoad(): void {
161+
this.#lazySeq += 1;
162+
const seq = this.#lazySeq;
163+
this.#lazyLayer = {};
164+
const locale = resolveProviderLocale(this);
165+
void (async () => {
166+
const merged = await mergeLocaleOverlays(locale, loadBuiltin, localeLookupChain);
167+
if (seq !== this.#lazySeq) return;
168+
this.#lazyLayer = merged;
169+
this.requestUpdate();
170+
})();
171+
}
172+
173+
#resolvedLocale(): Locale {
174+
return resolveProviderLocale(this);
175+
}
176+
177+
#publish(): void {
178+
const locale = this.#resolvedLocale();
179+
const registryLayer = getI18nTranslations(locale);
180+
const translations: Translations = {
181+
...registryLayer,
182+
...this.#lazyLayer,
183+
};
184+
const translator = createTranslator(translations, locale);
185+
this.#i18nProvider.setValue({ translator, locale });
186+
}
187+
188+
#syncCaptions(): void {
189+
selectCaptionsByLocale(this.#storeConsumer.value ?? undefined, this.#resolvedLocale());
190+
}
191+
}
192+
return I18nProviderElement;
193+
};
194+
195+
const TextMixin = <Base extends ReactiveElementMixinBase>(Base: Base) => {
196+
class MediaText extends Base {
197+
static properties = {
198+
...Base.properties,
199+
key: { type: String, reflect: true },
200+
};
201+
202+
key = '';
203+
204+
readonly #i18n = new I18nControllerImpl(this);
205+
206+
protected override updated(changed: PropertyValues): void {
207+
super.updated(changed);
208+
this.textContent = this.key ? translateReflectedKey(this.#i18n.value, this.key) : '';
209+
}
210+
}
211+
return MediaText;
212+
};
213+
214+
return {
215+
context: i18nContext,
216+
I18nController: I18nControllerImpl,
217+
ProviderMixin,
218+
TextMixin,
219+
};
220+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { ReactiveElement } from '@videojs/element';
2+
3+
import { safeDefine } from '../define/safe-define';
4+
import { I18nProviderMixin, I18nTextMixin } from './instance';
5+
6+
export class MediaI18nProviderElement extends I18nProviderMixin(ReactiveElement) {
7+
static readonly tagName = 'media-i18n-provider';
8+
}
9+
10+
safeDefine(MediaI18nProviderElement);
11+
12+
export class MediaTextElement extends I18nTextMixin(ReactiveElement) {
13+
static readonly tagName = 'media-text';
14+
}
15+
16+
safeDefine(MediaTextElement);
17+
18+
declare global {
19+
interface HTMLElementTagNameMap {
20+
[MediaI18nProviderElement.tagName]: MediaI18nProviderElement;
21+
[MediaTextElement.tagName]: MediaTextElement;
22+
}
23+
}

packages/html/src/i18n/index.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export type {
2+
Contains,
3+
Locale,
4+
TranslationParams,
5+
Translations,
6+
Translator,
7+
} from '@videojs/core/i18n';
8+
9+
export {
10+
createTranslator,
11+
getI18nTranslations,
12+
hasRegisteredI18n,
13+
localeLookupChain,
14+
onI18nRegistryChange,
15+
registerI18n,
16+
} from '@videojs/core/i18n';
17+
export type {
18+
CreateI18nOptions,
19+
CreateI18nResult,
20+
I18nContextValue,
21+
I18nLitContext,
22+
} from './create-i18n';
23+
export { createI18n } from './create-i18n';
24+
export { MediaI18nProviderElement, MediaTextElement } from './define-elements';
25+
export { context, I18nController, I18nProviderMixin, I18nTextMixin } from './instance';
26+
export { localeFromDomLang, resolvePlayerLocale, resolveProviderLocale } from './locale';
27+
export { selectCaptionsByLocale } from './select-captions-by-locale';
28+
29+
import './define-elements';

packages/html/src/i18n/instance.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { createI18n } from './create-i18n';
2+
3+
const built = createI18n();
4+
5+
export const context = built.context;
6+
export const I18nController = built.I18nController;
7+
export const I18nProviderMixin = built.ProviderMixin;
8+
export const I18nTextMixin = built.TextMixin;

packages/html/src/i18n/locale.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { Locale } from '@videojs/core/i18n';
2+
import { effectiveLocale, nearestLang } from '@videojs/utils/dom';
3+
import { isUndefined } from '@videojs/utils/predicate';
4+
5+
/** DOM `lang` values are untyped strings; align with core {@link Locale} at the boundary. */
6+
export function localeFromDomLang(raw: string | undefined): Locale | undefined {
7+
if (isUndefined(raw) || raw.trim() === '') {
8+
return undefined;
9+
}
10+
return raw as Locale;
11+
}
12+
13+
/** Delegates to {@link effectiveLocale}; result is typed as {@link Locale} for player UI. */
14+
export function resolvePlayerLocale(explicit: Locale | undefined, inherited: Locale | undefined): Locale {
15+
return effectiveLocale(explicit, inherited) as Locale;
16+
}
17+
18+
/** Effective locale for an i18n provider element (explicit `lang` → ancestor `lang` chain → `en`). */
19+
export function resolveProviderLocale(host: HTMLElement & { lang?: string }): Locale {
20+
const trimmed = host.lang?.trim();
21+
const explicit = trimmed ? (trimmed as Locale) : undefined;
22+
const root = host.parentElement ?? (typeof document !== 'undefined' ? document.documentElement : null);
23+
const inherited = localeFromDomLang(nearestLang(root));
24+
return resolvePlayerLocale(explicit, inherited);
25+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { englishTranslations } from '@videojs/core/i18n/locales/en';
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { AnyPlayerStore } from '@videojs/core/dom';
2+
import type { Locale } from '@videojs/core/i18n';
3+
import { localeLookupChain } from '@videojs/core/i18n';
4+
5+
function normalizeLangTag(tag: string): string {
6+
return tag.trim().replace(/_/g, '-').toLowerCase();
7+
}
8+
9+
/**
10+
* Picks caption/subtitle tracks matching {@link localeLookupChain} and shows the best match.
11+
* Other subtitle/caption tracks are disabled. No-op when there is no match (preserves user state).
12+
*/
13+
export function selectCaptionsByLocale(store: AnyPlayerStore | undefined, locale: Locale): void {
14+
const media = store?.target?.media;
15+
if (!media || !('textTracks' in media)) return;
16+
17+
const el = media as HTMLMediaElement;
18+
const candidates = [...el.textTracks].filter((t) => t.kind === 'captions' || t.kind === 'subtitles');
19+
if (!candidates.length) return;
20+
21+
const chain = localeLookupChain(locale).map(normalizeLangTag);
22+
let picked: TextTrack | undefined;
23+
for (const tag of chain) {
24+
picked = candidates.find((t) => {
25+
const lang = normalizeLangTag(t.language ?? '');
26+
return lang === tag || lang.startsWith(`${tag}-`) || tag.startsWith(`${lang}-`);
27+
});
28+
if (picked) break;
29+
}
30+
if (!picked) return;
31+
32+
for (const t of candidates) {
33+
t.mode = t === picked ? 'showing' : 'disabled';
34+
}
35+
}

0 commit comments

Comments
 (0)