Skip to content

Plugin Standard, v1.0

Status: Locked 2026-05-08. Audience: Core and library maintainers and third-party plugin authors.

This standard defines the contract every plugin extending Plugin<P, O, E> must follow. Lint rules enforce the MUST requirements. SHOULD requirements generate warnings. MAY requirements are available when applicable.

1. Required surface (MUST)

1.1 Identity

TypeScript
static readonly id: string;          // vendored: 'lyrics', 'fillz:viz'
static readonly version: string; // semver: '1.0.0'
static readonly description: string; // one-line, shown in errors + listings
static readonly minCoreVersion?: string; // optional minimum core version

Id conventions:

  • Core-shipped plugins: bare kebab-case ('lyrics', 'auto-advance')
  • Third-party / consumer plugins: vendor:name ('nomercy:sync', 'fillz:winamp')

1.2 Lifecycle methods

TypeScript
// Author-overridable hooks:
use(): void | Promise<void> // wire listeners, mount DOM, fetch resources
dispose(): void // explicit teardown

// Internal (base class, never override, never call from your plugin):
initialize(player, opts, lifecycle): void // core calls this before use() to inject player + opts + lifecycle

player.setup() awaits every plugin’s use() promise before emitting ready. A plugin whose use() rejects is marked failed, so it does not bring down the player. Other plugins continue.

1.3 State surface

Provided by the base class, no code needed for default behavior:

TypeScript
enabled(): boolean           // is the plugin active?
enable(): void // re-activate. Idempotent.
disable(): void // deactivate without unloading. Idempotent.
state(): PluginState<O> // { id, version, enabled, opts, runtime }
options(): Readonly<O> // current options (read)
options(partial): void // shallow-merge patch (write), emits opts:changed

Override protected getRuntimeState() to expose plugin-specific runtime fields in state().

1.4 Options handling

Options arrive via addPlugin(Class, opts). Default options(patch) shallow-merges and emits opts:changed. Override when you need to react to specific changes:

TypeScript
options(patch: Partial<O>) {
super.options(patch);

if ('endpoint' in patch) this.reconnect();
}

1.5 Event emission

TypeScript
// Fire-and-forget, auto-namespaced to plugin:<id>:<name>:
this.emit('dataFetched', { count: 5 });

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

Export your plugin’s event map type alongside the class:

TypeScript
export interface MyPluginEvents {
'dataFetched': { count: number };
}
export class MyPlugin extends Plugin<..., MyPluginEvents> { ... }
export type { MyPluginEvents };

Standard events fired automatically by the base class for every plugin:

EventPayloadWhen
plugin:<id>:installed{ id, version }After use() resolves
plugin:<id>:enabled{ id }After enable()
plugin:<id>:disabled{ id, reason }After disable()
plugin:<id>:opts:changed{ id, opts }After options(patch)
plugin:<id>:disposed{ id }After dispose()
plugin:<id>:failed{ id, error }When use() rejects

1.6 Event listening

Two typed forms. Both auto-dispose when the plugin disposes. No untyped fallback.

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

// Cross-plugin events, typed via imported class:
this.on(EqualizerPlugin, 'band:changed', ({ band }) => {
// band is EqBand, typed from EqualizerPlugin's E generic
});

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

1.7 Cancellable plugin events (dispatchBefore)

TypeScript
const result = await this.dispatchBefore('beforeSetBand', { index, gain });

BeforeEvent<TData> interface:

TypeScript
interface BeforeEvent<TData> {
data: TData; // mutable, player reads back after dispatch
preventDefault(): void; // skip the default action
isDefaultPrevented(): boolean;
stopImmediatePropagation(): void; // skip remaining listeners
isPropagationStopped(): boolean;
delay(promise: Promise<unknown>): void; // async-gate the action
isDelayed(): boolean;
}

dispatchBefore returns Promise<BeforeDispatchResult<TData>>:

FieldTypeMeaning
dataTDataPossibly mutated by listeners
preventedbooleanTrue if any listener prevented or any delay rejected
reasonstring'listener-prevented' or 'delay-rejected' or 'delay-timeout'
causeunknownUnderlying error when applicable

1.8 Error escalation

Never throw raw errors or call player.emit('error', ...) directly:

TypeScript
// Fatal: surfaces the error and aborts the current flow. Does NOT auto-disable
// the plugin — map the code to 'disable' in static onError for that.
this.throw({
severity: 'fatal',
code: 'my-plugin/connection-lost',
message: '...',
});

// Non-fatal: operation fails, plugin keeps running.
this.throw({
severity: 'error',
code: 'my-plugin/fetch-failed',
message: '...',
});

// Degradation: plugin survives, caller continues.
this.report({
severity: 'warning',
code: 'my-plugin/rate-limited',
message: '...',
});

// Observability.
this.report({
severity: 'info',
code: 'my-plugin/cache-hit',
message: '...',
});

2.1 Group methods for grouped state

TypeScript
class MyPlugin extends Plugin {
use() {
super.use();
this.addPlaybackHooks();
this.addNavigationHooks();
}
protected addPlaybackHooks() {
/* ... */
}
protected addNavigationHooks() {
/* ... */
}
}

Subclasses override individual groups. Empty body = skip that group.

2.2 Override hooks for customization

Expose protected methods for behaviors most likely to vary:

TypeScript
class MediaSessionPlugin extends Plugin {
protected getMetadata(item: T): MediaSessionMetadata { /* default */ }
}

class MyMediaSession extends MediaSessionPlugin {
protected getMetadata(item: T): MediaSessionMetadata {
return { title: item.localizedTitle };
}
}

2.3 Async setup via use() returning Promise

TypeScript
async use() {
const config = await this.fetch(this.opts.configUrl, { responseType: 'json' });
this.bindConfig(config);
}

ready fires only after all plugins’ use() promises resolve.

2.4 Read methods alongside mutators

If you have setBand(idx, gain), you must have getBand(idx). No write-only state.

2.5 Plugin-to-plugin discovery via requires

Class refs only, type-safe and refactor-safe:

TypeScript
class MyPlugin extends Plugin {
static readonly requires = [
AudioGraphPlugin, // required
{ plugin: MediaSessionPlugin, optional: true }, // optional
{ plugin: SpectrumPlugin, minVersion: '2.1.0' }, // version-pinned
];
}

Player at registration:

  • Required dep missing → throws core:plugin/missing-dep
  • Optional dep missing → debug warning; plugin runs
  • Version mismatch → throws core:plugin/version-mismatch

2.6 Wrap public mutators in dispatchBefore

TypeScript
async setBand(index: number, gain: number) {
const result = await this.dispatchBefore('beforeSetBand', { index, gain });
if (result.prevented) return;
this.applyBand(result.data.index, result.data.gain);
this.emit('band:changed', result.data);
}

2.7 Declare phase-aware advisories

TypeScript
static readonly advisories: PluginAdvisory[] = [
{
method: 'setCurrent',
duringPhase: ['playing'],
severity: 'warning',
reason: 'crossfade-disrupt',
message: 'Changing current track during playback skips the crossfade scheduler.',
},
];

3. Optional conventions (MAY)

3.1 Plugin replacement via replaces

TypeScript
class CapacitorMediaSession extends Plugin {
static readonly id = 'media-session';
static readonly replaces = 'media-session';
}
player.addPlugin(CapacitorMediaSession);

3.2 Plugin priority for shared event handling

TypeScript
class CriticalPlugin extends Plugin {
static readonly priority = 100; // higher = earlier; default 0
}

3.3 DOM mount point ownership

TypeScript
use() {
const root = this.mount('toast'); // returns a div appended to player.container
// auto-removed on dispose
}

Mount names are scoped per plugin, so MessagePlugin’s 'toast' becomes nmplayer-message-toast.

3.4 Plugin-scoped storage

TypeScript
this.storage.get('volume'); // reads 'nmplayer-<plugin-id>-volume'
this.storage.set('volume', 0.8);

3.5 Plugin-scoped logger

TypeScript
this.logger.debug('something'); // outputs [nmplayer][my-plugin] something

4. Naming conventions

Method naming

PatternExample
Stateful getter/setter overloadvolume() reads, volume(v) writes
Actionplay(), pause(), crossfadeTo()
State enumplayState(), shuffleState()
Boolean queryisLeader(), isMuted(), hasListeners()
Collection mutatorsqueueAppend, queueClear, queueRemove
Override hooksprotected addX() for groups, protected getX() for defaults
Lifecycle (author)use() to set up, dispose() to tear down (core calls initialize() first, base class; do not override)

Event naming

ContextConventionExample
Player core eventsBare nameplay, pause, ready
Plugin eventsplugin:<id>:<event> (auto-applied by this.emit)plugin:equalizer:band:changed
Subsystem events<subsystem>:<event>backend:changed, auth:refreshed
Stage eventsBare camelCasesetupStart, pluginsRegistered
Lifecycle phase eventsbefore<X> (pre) / bare <X> (post)beforePlay, play

Option key naming

  • camelCase
  • Boolean flags: positive form (enabled, not disabled)
  • Values with units: include unit (durationMs, frameRate, cooldownMs)
  • Callbacks: on<Event> for event-shaped, <verb> for transforms (transformUrl)

5. Event emission rules

  • Plugin → player: this.emit(name, data) only
  • Plugin → plugin: this.player.on('plugin:<other-id>:<event>', handler) via the class-form
  • Plugin → iframe host: embedPlugin.sendToHost(message), no direct postMessage

Events fire after the state change they describe. Pre-action events use before<X> naming.

6. Player phases and dispatch context

6.1 Phase

player.phase() returns one of 13 stable values:

PhaseTransitions to
idlesetup
setupready
readyloading, starting
loadingready
startingplaying
playingbuffering, seeking, paused
bufferingplaying, seeking
seekingplaying, buffering
pausedstarting, ended, stopped
endedstarting, disposing
stoppedstarting, disposing
disposingdisposed
disposed

Listen to the phase event for transitions: { from: PlayerPhase; to: PlayerPhase }.

6.2 Dispatch context

player.dispatching() returns the active event chain:

TypeScript
[]['beforePlay'][('beforePlay', 'beforeMutation')]; // no event in flight // inside a beforePlay listener // nested

6.3 Plugin advisories

Declarative phase-aware contracts:

TypeScript
interface PluginAdvisory {
method: string;
duringPhase?: PlayerPhase | PlayerPhase[];
duringEvent?: string | string[];
severity: 'info' | 'warning' | 'error';
reason: string;
message: string;
}

7. Error handling and recovery

Quarantine

Any throw inside a plugin is caught, wrapped in PluginError, emitted, and the plugin is marked failed. The player and other plugins continue.

Recovery policy

TypeScript
class MyPlugin extends Plugin {
static readonly onError = {
'core:cue/load-failed': 'retry-once',
'core:auth/forbidden': 'disable',
};
}

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

Dependency cascade

Dep eventEffect on dependents
Dep never registeredaddPlugin(dependent) throws synchronously
Dep use() fails after dependent registeredCascade-disable dependents
Dep disable()’dCascade-disable dependents
removePlugin(dep) with dependents registeredThrows unless { cascade: true }
Dep manually re-enabledDependents do NOT auto-re-enable

8. Replacement and extension

Swap via same id + replaces

TypeScript
class CapacitorMediaSession extends Plugin {
static readonly id = 'media-session';
static readonly replaces = 'media-session';
}

Extend via subclass

TypeScript
class MyKeyHandler extends KeyHandlerPlugin {
static readonly id = 'key-handler';
protected addPlaybackKeys() {
this.bind('p', (player) => player.play());
}
}

Parameterize via derive()

TypeScript
const StrictMediaSession = MediaSessionPlugin.derive({ requireMetadata: true });

Override as last resort (tier 4)

TypeScript
const restore = player.experimental.override('subtitles', () => mySubtitles);
// call restore() to undo — there is no auto-restore on dispose

Lint-flagged. Requires inline comment explaining why no lower tier works.

9. Testing

Leak harness (required)

TypeScript
import {
assertNoListenerLeak,
createStubPlayer,
} from '@nomercy-entertainment/nomercy-player-core/testing';
import { LifecycleRegistry } from '@nomercy-entertainment/nomercy-player-core';

// describePlugin auto-asserts no listener leak after every test.
// Use assertNoListenerLeak directly when you need an explicit stand-alone check:
test('no listener leak', async () => {
const player = createStubPlayer();
const plugin = new MyPlugin();
const lifecycle = new LifecycleRegistry();

await assertNoListenerLeak({
subjectId: 'my-plugin',
player,
setup: async () => {
plugin.initialize(player, {}, lifecycle);
await plugin.use();
},
teardown: () => {
plugin.dispose();
lifecycle.dispose();
},
});
});

Behavioral test pattern

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

describePlugin(MyPlugin, ({ player, plugin }) => {
test('emits dataFetched on track change', async () => {
const events: unknown[] = [];
player.on('plugin:my-plugin:dataFetched', (event) => events.push(event));
// Trigger the event the plugin listens for to drive the behavior under test:
player.emit('current', { item: { id: '1', name: 'Track', url: '...' } });
await vi.waitFor(() => events.length > 0);
expect(events[0]).toMatchObject({ count: expect.any(Number) });
});
});

describePlugin provides a fresh player + registered plugin per test with auto-dispose in afterEach.

10. Conformance checklist

A plugin conforms when:

  • static readonly id is vendored
  • static readonly version is a semver string
  • static readonly description is a one-liner
  • use() and dispose() defined or inherited
  • All listeners use this.on / this.listen (not raw 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 declared and exported
  • static requires declared if depending on other plugins
  • Tests via describePlugin cover behavior + assertNoListenerLeak
  • No eslint-disable-next-line nmplayer/... without explanation comment