|
| 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 | +} |
0 commit comments