Skip to content

Core: i18n

Translation is a swappable adapter like storage or the platform bundle, with a working default: DefaultTranslator, seeded with the kit’s own core.* strings, English loaded eagerly and every other kit locale loaded lazily on first use, ready before a single plugin registers.

Resolving a key

t(key, vars?) walks the active language’s BCP-47 fallback chain (pt-BRpt → the configured fallbackLanguage, 'en' by default) over an in-memory bundle table, and substitutes {var} placeholders from vars. A key with no value anywhere in the chain falls through to onMissingTranslation(key, lang) if you configured one, then to the key itself, so a missing translation degrades to something legible instead of throwing.

language() reads the active tag; language(lang) dispatches beforeLanguage (cancellable and redirectable, same shape as every other before* event), switches the translator, and, for a real player with plugins registered, loads every plugin’s translation bundle for the new tag and its BCP-47 parent chain before resolving. addTranslations(bundle) merges a bundle into the live table directly, last-write-wins per key.

Swap the whole engine via setup({ translator }) when you need pluralization, gender, or ICU MessageFormat. The kit uses your engine as-is and does not forward the translations or loadTranslations config to it; those seed only the built-in DefaultTranslator, so a custom engine owns its own bundle loading. The configured startup language still reaches it, since the player calls language() on whatever translator is active. This is the same DI seam Adapters & Dependency Injection describes for every other port.

Plugin translations

A plugin’s static translations field is auto-merged on use() and auto-removed on dispose(), walked up the full prototype chain so a subclass’s bundle doesn’t shadow its parent’s. Keys are namespaced plugin.<id>.*, which is exactly what Plugin.t(key) prepends automatically, so plugin authors never hand-roll the prefix. Bundles can be eager (pre-resolved per language) or lazy (loaded from translationsFromGlob-style function loaders), and lazy bundles only fetch the active language and its parent chain, not every language a plugin ships.

Title tokens

registerTitleTokens({ S: 'plugin.desktop-ui.token.season', E: '...' }) maps a single letter to a translation key. Every playlist item’s title field passes through interpolateTitleTokens() on ingest (queue(), queueAppend(), and the rest), resolving %S1%E3-style tokens against the registry. Core ships an empty registry, so this is a zero-cost no-op until a player library opts in.

TypeScript
/**
* The i18n engine. `t(key, vars?)` resolves a key through the active
* language's bundle, with `{var}` interpolation and a BCP-47 fallback chain
* (`pt-BR` -> `pt` -> `en`). `addTranslations()` merges a bundle into the
* live table; `language(tag)` switches languages and (for a real player)
* loads every registered plugin's translation bundle for the new tag.
*/

import { tourPlayer } from './tour-player';

const player = tourPlayer('i18n-demo');
player.setup({ logLevel: 'info', language: 'en' });
await player.ready();

player.addTranslations({
en: { 'demo.welcome': 'Welcome, {name}!' },
nl: { 'demo.welcome': 'Welkom, {name}!' },
});

console.log(player.t('demo.welcome', { name: 'Ada' })); // 'Welcome, Ada!'

await player.language('nl');
console.log(player.language()); // 'nl'
console.log(player.t('demo.welcome', { name: 'Ada' })); // 'Welkom, Ada!'

console.log(player.t('demo.missing')); // 'demo.missing' — unresolved keys fall back to the key itself

await player.dispose();

Next steps

  • Cue Parsers: the same registry-and-default-implementation shape, applied to subtitle and lyric formats.