Core: The Plugin Base
Everything a plugin needs to extend a player without forking it lives on one class: Plugin<P, O, E>.
What you get for free
Subclass Plugin and initialize() (called by the player, never by you) wires a scoped logger (prefixed [nmplayer][<id>]) and a namespaced storage (keys auto-prefixed nmplayer-<id>- so plugins never collide with each other or with player-level keys). From there, every helper on the base class auto-cleans on dispose():
on/once/off/hasListeners: two forms, a string for player events or a plugin class for another plugin’s namespaced events (on(OtherPlugin, 'event', fn)), both auto-removed on teardown.listen/timeout/interval/frame/abortable: DOM listeners, timers, an animation-frame loop, and anAbortController, all released together.lifecycle.observe(observer): wraps aResizeObserver,MutationObserver, orIntersectionObserverso it disconnects on dispose. Returns the observer, so you chain theobserve(el)straight onto it.emit(event, data): fire-and-forget, auto-namespaced underplugin:<id>:.dispatchBefore(event, data, opts?): the cancellable, mutable, async-aware pattern from Transport Contract, available for your own actions.fetch(url, options?)/websocket(url, opts?): auth-aware HTTP and an auto-reconnecting realtime channel, both bound to the plugin’s lifecycle. See Recipe: Use authFetch for theparsercallback on a'text'response.t(key, vars?): translation, auto-namespaced underplugin.<id>.*.throw(payload)/report(payload): structured error escalation,throwaborting the current flow,reportjust surfacing a warning.mount(name): a<div>on the player container, namespacednmplayer-<id>-<name>, removed on dispose.
Reach for these helpers rather than the raw primitives. Calling this.player.on / .emit directly, or using a raw setTimeout, addEventListener, new ResizeObserver, a raw throw, or the global fetch, all skip the scoping, cleanup, auth, and structured-error handling the helpers give you. Each one leaks something past dispose() or drops off a contract, so the @nomercy-entertainment/eslint-plugin-player rules flag them at write time. When a plugin genuinely needs the raw form — a public URL that must not carry auth, a third-party emitter that is not an EventTarget — an eslint-disable-next-line with a one-line reason is the intended escape hatch.
Declaring the contract
Static fields describe the plugin to the registration pipeline: id (required), description, version, minCoreVersion (checked against the running kit build), requires (class refs, optionally { plugin, optional, minVersion }), replaces (opt-in same-id swap), priority (event-handler ordering), and translations (auto-merged on use(), auto-removed on dispose). A missing required dependency throws core:plugin/missing-dep at registration; an unmet minVersion throws core:plugin/version-mismatch.
Override use() to wire up (it can return a Promise, awaited by the player before ready fires, bounded by pluginInitTimeoutMs). Override dispose() only for resources the lifecycle helpers above don’t already track.
Registering one
player.addPlugin(PluginClass, opts?) queues the plugin before setup() completes, or registers it inline afterward. getPlugin(PluginClass) (class-typed) or getPluginById(id) retrieves the live instance later. The example below defines a small plugin that counts play() calls and emits a namespaced milestone event.
/**
* The `Plugin` base class. Subclassing it gets you a scoped logger,
* namespaced storage, a cancellable `dispatchBefore` surface, namespaced
* i18n, and auto-cleanup timers/listeners — all torn down together when the
* plugin (or the player) disposes. This plugin counts `play()` calls and
* emits a namespaced milestone event every N plays.
*/
import type { BaseEventMap, IPlayer } from '@nomercy-entertainment/nomercy-player-core';
import { Plugin } from '@nomercy-entertainment/nomercy-player-core';
import { tourPlayer } from './tour-player';
interface PlayCounterEvents {
milestone: { count: number };
}
class PlayCounterPlugin extends Plugin<
IPlayer<BaseEventMap>,
{ everyNPlays?: number },
PlayCounterEvents
> {
static override readonly id = 'play-counter';
static override readonly version = '1.0.0';
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 ?? 5;
this.on('play', () => {
this.plays += 1;
this.logger.debug(`play #${this.plays}`);
if (this.plays % every === 0) {
this.emit('milestone', { count: this.plays }); // namespaced as plugin:play-counter:milestone
}
});
}
protected override getRuntimeState(): Record<string, unknown> {
return { plays: this.plays };
}
}
const player = tourPlayer('plugin-demo');
player.addPlugin(PlayCounterPlugin, { everyNPlays: 2 });
player.setup({ logLevel: 'info' });
await player.ready();
player.on('plugin:play-counter:milestone', ({ count }) => {
console.log('milestone reached:', count);
});
await player.play();
await player.pause();
await player.play(); // second play() call — fires the milestone at everyNPlays: 2
const instance = player.getPlugin(PlayCounterPlugin);
console.log(instance?.state().runtime); // { plays: 2 }
await player.dispose();
Next steps
- Adapters & Dependency Injection: the swappable ports (
IStorage,IPlatform,IStreamSource, and the rest) a plugin’s own dependencies are built from.