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 needsthis.player.item(). BothNMVideoPlayer<T>andNMMusicPlayer<T>satisfy this constraint structurally. - Use the concrete player type (
NMVideoPlayer,NMMusicPlayer) when you need library-specific methods likesubtitles()orcrossfade().
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
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:
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:
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.
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.
| Helper | Returns | Replaces | Released on dispose |
|---|---|---|---|
this.listen(target, event, handler, options?) | void | target.addEventListener | listener removed |
this.timeout(fn, ms) | number (handle) | setTimeout | timer cleared |
this.interval(fn, ms) | number (handle) | setInterval | timer cleared |
this.frame(fn) | () => void (cancel) | a requestAnimationFrame loop | loop cancelled |
this.abortable() | AbortController | new AbortController() | abort() called |
this.websocket(url, opts?) | IRealtimeChannel | new WebSocket(url) | channel closed |
this.mount(name) | HTMLDivElement | container.appendChild(...) | node removed |
this.appendStyles(href, id) | void | a <link> in <head> | stays (once per id) |
Listeners and timers
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:
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
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.
// 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
// 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:
| Severity | Channel | Aborts 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. |
// 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:
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:
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:
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:
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:
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:
class MyKeyHandler extends KeyHandlerPlugin {
static readonly id = 'my-key-handler';
protected addPlaybackKeys(): void {
super.addPlaybackKeys();
this.bind('p', (player) => player.play());
}
}
DOM and storage
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
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 idis vendored (vendor:name)static readonly versionis a valid semver stringstatic readonly descriptionis a one-lineruse()anddispose()are defined or inherited- All listeners use
this.on(...)(neverplayer.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
Egeneric is declared and exported static requiresis declared when depending on other plugins- Tests cover behavior and
assertNoListenerLeak