Skip to content

Build a Player: Add i18n

The last step. Step 3 left a player with a real backend and a registered plugin. This step adds translations, and the class that comes out the other end is the complete one this tutorial was building toward.

Config, not a separate call

i18n showed addTranslations() merging a bundle into an already-running player. That’s the right tool for translations that arrive after setup, loaded from a network bundle or registered by a plugin. Here, the translations are known upfront, so they go straight into setup() alongside logLevel and everything else: language and translations are ordinary BasePlayerConfig fields.

The kit ships its own core.* strings out of the box, network, auth, DRM, and accessibility-announcement copy among them, so a player has working error and status text before you write a single translation key. English is seeded eagerly at setup(), merged directly underneath whatever translations.en you pass, so t('core.*') resolves immediately and a key you don’t override still resolves to the kit’s English copy. Every other kit locale loads lazily instead, the first time language(lang) switches to a tag with no bundle seeded for it yet, the same on-demand mechanism plugin translation bundles use, passing your own static translations.nl (as above) seeds that tag directly and takes over from the kit’s lazy nl bundle rather than merging with it. Supplying your own translator engine replaces this default merge entirely, the kit only owns translation state for its built-in DefaultTranslator.

Adding it

Nothing about KitPlayer from Step 3 changes structurally, two declares, one each for t() and language(), and two config fields at the setup() call site.

TypeScript
/**
* Build a player, step 4: add i18n.
*
* Same `KitPlayer` as step 3 (compose, backend, plugin), with translations
* supplied at `setup()` time instead of bolted on afterward — `language` and
* `translations` are ordinary `BasePlayerConfig` fields, resolved by the same
* pipeline that wires everything else. The kit's English bundle is always
* merged underneath whatever you pass, so an incomplete translation degrades
* to English instead of a blank string. This is the last step: the class
* below now composes every capability this tutorial covers.
*/

import type {
BaseEventMap,
BasePlayerConfig,
IPlayer,
IPlayerBackend,
PlayerPhase,
PluginCtorWithId,
} from '@nomercy-entertainment/nomercy-player-core';
import {
composeMixins,
EventEmitter,
initPlayerCoreState,
Plugin,
playerCoreMethods,
resolvePlayerConstructor,
} from '@nomercy-entertainment/nomercy-player-core';

interface FakeBackendShape extends IPlayerBackend {
play(): void;
pause(): void;
currentTime(seconds: number): void;
volume(level: number): void;
}

class FakeBackend implements FakeBackendShape {
readonly calls: string[] = [];
private playing = false;

play(): void {
this.playing = true;
this.calls.push('play');
}

pause(): void {
this.playing = false;
this.calls.push('pause');
}

currentTime(seconds: number): void {
this.calls.push(`currentTime(${seconds})`);
}

volume(level: number): void {
this.calls.push(`volume(${level})`);
}

state(): 'playing' | 'idle' {
return this.playing ? 'playing' : 'idle';
}
}

const _instances = new Map<string, KitPlayer>();

class KitPlayer extends EventEmitter<BaseEventMap> {
playerId = '';
container: HTMLElement = {} as HTMLElement;

get id(): string {
return this.playerId;
}

declare setup: (config: BasePlayerConfig) => this;
declare ready: () => Promise<void>;
declare dispose: () => void;
declare phase: () => PlayerPhase;
declare play: () => Promise<void>;
declare pause: () => Promise<void>;
declare time: { (): number; (seconds: number): Promise<void> };
declare volume: { (): number; (level: number): Promise<void> };

declare addPlugin: <P extends Plugin<any, any, any>>(
PluginClass: PluginCtorWithId & (new () => P),
opts?: P['opts'],
) => this;
declare getPlugin: <P extends object>(
PluginClass: PluginCtorWithId & (new () => P),
) => P | undefined;

// New in this step — the i18n surface, resolved by the same setup pipeline.
declare t: (key: string, vars?: Record<string, string>) => string;
declare language: { (): string; (lang: string): Promise<void> };

private _backend: FakeBackend | undefined;
backend(): FakeBackend {
if (!this._backend) {
this._backend = new FakeBackend();
}
return this._backend;
}

constructor(id?: string | number) {
super();
const resolved = resolvePlayerConstructor(id, _instances, 'KitPlayer');
if (resolved.kind === 'existing') {
return resolved.instance as unknown as this;
}

initPlayerCoreState(this, { className: 'KitPlayer' });
this.playerId = resolved.id;
this.container = resolved.div;
_instances.set(resolved.id, this);
}
}

composeMixins(KitPlayer.prototype, ...playerCoreMethods);

export function kitPlayer(id?: string | number): KitPlayer {
return new KitPlayer(id);
}

interface PlayCounterEvents {
milestone: { count: number };
}

class PlayCounterPlugin extends Plugin<
IPlayer<BaseEventMap>,
{ everyNPlays?: number },
PlayCounterEvents
> {
static override readonly id = 'play-counter';
static override readonly description =
'Counts play() calls and reports a milestone every N plays.';

private plays = 0;

override use(): void {
const every = this.opts.everyNPlays ?? 2;
this.on('play', () => {
this.plays += 1;
if (this.plays % every === 0) {
this.emit('milestone', { count: this.plays });
}
});
}
}

// Usage — translations passed straight into setup(), same config object that
// carries logLevel, plugins, and every adapter override from earlier steps.
const player = kitPlayer('build-step-4');
player.addPlugin(PlayCounterPlugin, { everyNPlays: 2 });

player.setup({
logLevel: 'info',
language: 'en',
translations: {
en: { 'app.welcome': 'Welcome, {name}!' },
nl: { 'app.welcome': 'Welkom, {name}!' },
},
});
await player.ready();

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

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

await player.play();
console.log(player.backend().calls); // ['play'] — every capability from steps 1-4 composes onto one instance

await player.dispose();

What you built

Four steps, one class: composeMixins gives it every shared method, backend() gives transport and volume something to drive, addPlugin layers behavior on top without touching the class, and translations in setup() finishes the config. Every piece is the same code nomercy-video-player and nomercy-music-player run, this tutorial just built it up one capability at a time instead of getting it all from a single import.

Next steps

  • Recipes: task-sized walkthroughs for real kit jobs, swapping an adapter, registering a cue parser, calling authFetch, and providing a custom URL resolver.