Skip to content

Plugin Authoring

Plugins are the extension point for the Player SDK. Every feature beyond core transport lives in a plugin: the built-in UI, EQ, lyrics, skip markers, Chromecast handoff. Your app-specific features follow the same contract.

For the locked conformance document that lint rules enforce, see Plugin Standard.

Before you write a plugin

Decide which layer your plugin belongs to:

  • Layer 4 (built-in): generic domain behavior. No NoMercy server URLs, no SignalR, no app tokens. Should work for any consumer.
  • Layer 5 (consumer): your app-specific glue. Can import from anywhere.

Both layers use the same Plugin<P, O, E> base class. The distinction is what they import and what goes in their options.

Typing your plugin’s player generic

The P generic controls which player API your plugin can access.

  • Use IPlayer<BaseEventMap> for core-level plugins that don’t touch the active item (analytics, error reporters, adapters). metrics(), getPlugin(), and all transport methods are available directly.
  • Add & WithCurrentItem<YourItemType> when your plugin needs this.player.item(). Both NMVideoPlayer<T> and NMMusicPlayer<T> satisfy this constraint structurally.
  • Use the concrete player type (NMVideoPlayer, NMMusicPlayer) when you need library-specific methods like subtitles() or crossfade().
TypeScript
import { Plugin } from '@nomercy-entertainment/nomercy-player-core';
import type {
IPlayer,
WithCurrentItem,
BaseEventMap,
} from '@nomercy-entertainment/nomercy-player-core';

// Core-level plugin — no item access, metrics available directly:
class AnalyticsPlugin extends Plugin<IPlayer<BaseEventMap>, AnalyticsOptions, AnalyticsEvents> {
private report(): void {
const snapshot = this.player.metrics(); // PlaybackMetrics — no constraint needed
}
}

// Item-aware plugin — needs current(), adds WithCurrentItem:
class MyPlugin<
P extends IPlayer<BaseEventMap> & WithCurrentItem<MyItem>,
I extends MyItem = MyItem,
> extends Plugin<P, MyOptions, MyEvents> {
onEnded(): void {
const item = this.player.item(); // MyItem
const snapshot = this.player.metrics(); // PlaybackMetrics — no constraint needed
}
}

Plugins that don’t read the active item omit & WithCurrentItem<...>; metrics(), getPlugin(), and transport methods are on IPlayer directly.

Minimal plugin skeleton

TypeScript
import { Plugin } from '@nomercy-entertainment/nomercy-player-core';
import type { NMVideoPlayer } from '@nomercy-entertainment/nomercy-video-player';

interface ScrobbleOptions {
endpoint: string;
minWatchedPercent?: number; // default 50 (percentage 0–100)
}

interface ScrobbleEvents {
scrobbled: { itemId: string; watchedPercent: number };
scrobbleFailed: { itemId: string; reason: string };
}

export class ScrobblePlugin extends Plugin<NMVideoPlayer, ScrobbleOptions, ScrobbleEvents> {
static readonly id = 'nomercy:scrobble';
static readonly version = '1.0.0';
static readonly description = 'Reports watch progress to the NoMercy server';

use(): void {
this.on('progress', ({ percentage }) => {
const threshold = this.opts.minWatchedPercent ?? 50;
if (percentage >= threshold) {
this.scrobble(percentage);
}
});
}

dispose(): void {
// this.on listeners auto-dispose, nothing extra needed here
}

private async scrobble(percentage: number): Promise<void> {
const item = this.player.item();
if (!item) return;

try {
await this.fetch(this.opts.endpoint, {
method: 'POST',
responseType: 'json',
body: JSON.stringify({ id: item.id, percentage }),
});
this.emit('scrobbled', { itemId: String(item.id), watchedPercent: percentage });
} catch (error) {
this.report({
severity: 'warning',
code: `${ScrobblePlugin.id}/fetch-failed`,
message: 'Scrobble request failed',
});
this.emit('scrobbleFailed', { itemId: String(item.id), reason: String(error) });
}
}
}

export type { ScrobbleEvents };

Usage:

TypeScript
player.addPlugin(ScrobblePlugin, { endpoint: '/api/v1/progress' });

player.on('plugin:nomercy:scrobble:scrobbled', ({ itemId, watchedPercent }) => {
console.log(`Scrobbled ${itemId} at ${watchedPercent}%`);
});

Identity fields

Every plugin must declare three static fields:

TypeScript
static readonly id = 'vendor:name';         // kebab-case. Bare for core plugins, vendor:name for third-party
static readonly version = '1.0.0'; // semver string
static readonly description = 'One-liner'; // shown in error messages and plugin listings

The id is the globally unique key. Core-shipped plugins use bare kebab-case ('equalizer', 'auto-advance'). Third-party plugins must use a vendor prefix ('nomercy:sync', 'fillz:winamp').

Lifecycle

use() is called during player.setup(). Return a Promise<void> if you need to load config or register async resources, so ready fires only after all plugins’ use() promises resolve.

TypeScript
async use(): Promise<void> {
const config = await this.fetch(this.opts.configUrl, { responseType: 'json' });
this.applyConfig(config);
this.on('current', ({ item }) => this.loadMetadata(item));
}

dispose() is called on player.dispose() or player.removePlugin(). All timers, intervals, animation frames, and DOM mounts registered through the plugin’s helper methods (this.timeout, this.interval, this.frame, this.mount) auto-dispose, so you only need to explicitly clean up external resources.

Lifecycle helpers

Allocate every resource through one of these protected helpers instead of the raw browser API. Each one registers its own teardown with the plugin’s lifecycle registry, so it is released automatically on dispose() and you never write the cleanup yourself. dispose() is reserved for the things the kit cannot see — third-party library instances, custom observers, a WebGLRenderingContext.

HelperReturnsReplacesReleased on dispose
this.listen(target, event, handler, options?)voidtarget.addEventListenerlistener removed
this.timeout(fn, ms)number (handle)setTimeouttimer cleared
this.interval(fn, ms)number (handle)setIntervaltimer cleared
this.frame(fn)() => void (cancel)a requestAnimationFrame looploop cancelled
this.abortable()AbortControllernew AbortController()abort() called
this.websocket(url, opts?)IRealtimeChannelnew WebSocket(url)channel closed
this.mount(name)HTMLDivElementcontainer.appendChild(...)node removed
this.appendStyles(href, id)voida <link> in <head>stays (once per id)

Listeners and timers

TypeScript
use(): void {
this.listen(document, 'keydown', this.onKey); // not document.addEventListener
this.timeout(() => this.hideToast(), 3000); // not setTimeout
this.interval(() => this.poll(), 10_000); // not setInterval
}

this.listen is the one to reach for over a raw addEventListener — an untracked listener is the most common source of ghost handlers firing after the player is gone. timeout and interval return their numeric handle if you want to clear one early; most callers discard it.

Animation frames, and cancelling one yourself

this.frame(fn) drives a requestAnimationFrame loop and hands your callback the frame delta and the timestamp. The loop is cancelled automatically on dispose() — but it also returns a disposer so you can stop one loop early, by hand, without tearing down the whole plugin:

TypeScript
use(): void {
const stopRender = this.frame((deltaMs, time) => {
this.render(time);
});

// Later — stop this one loop while the plugin (and its other loops) keep running:
this.on('ended', () => stopRender());
}

That is the answer to “how do I dispose a frame() loop programmatically”: keep the returned function and call it. For a single deferred write rather than a continuous loop, use this.timeout(fn, 0) instead.

Abort controllers, sockets, mounts, and styles

TypeScript
use(): void {
// AbortController that aborts on dispose — hand its signal to any Web API:
const controller = this.abortable();
void fetch(this.opts.url, { signal: controller.signal });

// Auto-reconnecting realtime channel, closed on dispose:
const channel = this.websocket('wss://example.com/sync');
channel.on('message', e => this.handle(e));

// A namespaced <div> on the player container (class `nmplayer-<id>-<name>`),
// removed on dispose:
const panel = this.mount('panel');
panel.textContent = 'ready';

// A stylesheet added to <head> once per id (kept in place, shared):
this.appendStyles(new URL('./styles.css', import.meta.url).href, 'myplugin-styles');
}

this.fetch(...) (auth-aware HTTP) and this.resolveUrl(...) (auth-aware URL resolution) are covered under Auth and Fetch; this.t(...) under Internationalisation.

Event listening

Always use this.on(...) inside a plugin, never player.on(...). The this.on form auto-disposes with the plugin.

TypeScript
// Player events:
this.on('time', ({ time }) => this.updateDisplay(time));

// Cross-plugin events (typed via class reference):
this.on(EqualizerPlugin, 'band:changed', ({ band }) => {
this.syncBand(band);
});

off, once, and hasListeners have the same overloaded shapes.

Emitting events

TypeScript
// Fire-and-forget (auto-namespaced to plugin:<id>:<name>):
this.emit('scrobbled', { itemId: '123', watchedPercent: 0.85 });

// Cancellable before-action:
const result = await this.dispatchBefore('beforeSyncEq', { index, gain });
if (result.prevented) return;
this.applyBand(result.data.index, result.data.gain);
this.emit('band:changed', result.data);

Error handling

Never throw raw errors or call player.emit('error', ...) directly. Use this.throw(...) (aborts the current flow, returns never) or this.report(...) (surfaces without aborting). Both carry a severity that decides what the error means:

SeverityChannelAborts flow?Meaning
fatal'fatal'yes (throw)Unrecoverable. The player is going down. Consumer tears down or shows a fatal screen.
error'error' + 'plugin:error'yes (throw)Recoverable. This operation failed, but the plugin keeps running for future events.
warning'warning' + 'plugin:warning'no (report)Degradation. Playback continues, consumer is informed.
info'info'no (report)Observability only.
TypeScript
// fatal: the player cannot continue. Surfaced on the 'fatal' channel and aborts.
this.throw({
severity: 'fatal',
code: 'nomercy:scrobble/connection-lost',
message: 'Cannot reach scrobble endpoint',
});

// error: this operation failed; the plugin survives and reacts to later events.
this.throw({
severity: 'error',
code: 'nomercy:scrobble/fetch-failed',
message: 'Scrobble request returned an error',
});

// warning: degraded but playback continues.
this.report({
severity: 'warning',
code: 'nomercy:scrobble/rate-limited',
message: 'Scrobble rate-limited, will retry',
});

fatal does not auto-dispose the player, it raises the contract-level “shutting down” signal. The consumer decides the teardown:

TypeScript
player.on('fatal', ({ error }) => {
showFatalScreen(error.message);
player.dispose();
});

Disabling a plugin in response to an error is a separate concern, so declare it as a recovery action, not a severity.

Static error recovery

Declare a recovery policy to control what happens when a specific error code is emitted by the player or by a dependency:

TypeScript
static readonly onError: Record<string, PluginRecoveryAction> = {
'core:cue/load-failed': 'retry-once',
'core:auth/forbidden': 'disable',
'nomercy:scrobble/fetch-failed': 'ignore',
};

Options: 'retry-once' | 'fallback' | 'disable' | 'ignore'. These are declared per plugin through static onError; there is no setup-level override.

Plugin dependencies

Declare required or optional plugin dependencies using class references, never string ids:

TypeScript
static readonly requires = [
AudioGraphPlugin, // required
{ plugin: MediaSessionPlugin, optional: true }, // optional: plugin still runs without it
{ plugin: SpectrumPlugin, minVersion: '2.1.0' }, // version-pinned
];

Phase advisories

Declare which phases are safe for each method. When a guarded mutation matches, the player emits the advisory’s severity event (info / warning / error) — in all environments, not just development:

TypeScript
static readonly advisories: PluginAdvisory[] = [
{
method: 'scrobble',
duringPhase: ['idle', 'setup'],
severity: 'warning',
reason: 'no-item-loaded',
message: 'Scrobble called before any item is loaded, will be a no-op.',
},
];

Plugin replacement and extension

Replace a built-in plugin with the same id:

TypeScript
import { MediaSessionPlugin } from '@nomercy-entertainment/nomercy-player-core/plugins/media-session';
import type { MediaSessionMetadata } from '@nomercy-entertainment/nomercy-player-core/plugins/media-session';

class MyMediaSession extends MediaSessionPlugin {
static readonly id = 'media-session';
static readonly replaces = 'media-session';

protected getMetadata(item): MediaSessionMetadata {
return { title: item.title };
}
}
player.addPlugin(MyMediaSession);

Extend without replacing:

TypeScript
class MyKeyHandler extends KeyHandlerPlugin {
static readonly id = 'my-key-handler';

protected addPlaybackKeys(): void {
super.addPlaybackKeys();
this.bind('p', (player) => player.play());
}
}

DOM and storage

TypeScript
use(): void {
const root = this.mount('toast'); // returns a div appended to player.container
// auto-removed on dispose
this.storage.set('volume', '0.8'); // values are strings; key namespaced to nmplayer-<id>-volume
this.storage.get('volume');
}

Testing

TypeScript
import { describePlugin } from '@nomercy-entertainment/nomercy-player-core/testing';

describePlugin(ScrobblePlugin, ({ player, plugin }) => {
test('emits scrobbled after progress threshold', async () => {
const events: unknown[] = [];
player.on('plugin:nomercy:scrobble:scrobbled', (event) => events.push(event));

player.emit('progress', { time: 90, duration: 100, percentage: 0.9 });

await vi.waitFor(() => events.length > 0);
expect(events[0]).toMatchObject({ watchedPercent: 0.9 });
});
});

describePlugin provides a fresh player with the plugin registered, auto-disposes in afterEach, and automatically asserts no listener leak after each test.

Conformance checklist

Before shipping a plugin:

  • static readonly id is vendored (vendor:name)
  • static readonly version is a valid semver string
  • static readonly description is a one-liner
  • use() and dispose() are defined or inherited
  • All listeners use this.on(...) (never player.on(...))
  • All timers use this.timeout(...) / this.interval(...) / this.frame(...)
  • All errors use this.throw(...) / this.report(...)
  • Every mutator has a paired reader
  • Plugin’s E generic is declared and exported
  • static requires is declared when depending on other plugins
  • Tests cover behavior and assertNoListenerLeak