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
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
// 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:
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:
options(patch: Partial<O>) {
super.options(patch);
if ('endpoint' in patch) this.reconnect();
}
1.5 Event emission
// 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:
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:
| Event | Payload | When |
|---|---|---|
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.
// 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)
const result = await this.dispatchBefore('beforeSetBand', { index, gain });
BeforeEvent<TData> interface:
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>>:
| Field | Type | Meaning |
|---|---|---|
data | TData | Possibly mutated by listeners |
prevented | boolean | True if any listener prevented or any delay rejected |
reason | string | 'listener-prevented' or 'delay-rejected' or 'delay-timeout' |
cause | unknown | Underlying error when applicable |
1.8 Error escalation
Never throw raw errors or call player.emit('error', ...) directly:
// 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. Recommended conventions (SHOULD)
2.1 Group methods for grouped state
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:
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
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:
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
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
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
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
class CriticalPlugin extends Plugin {
static readonly priority = 100; // higher = earlier; default 0
}
3.3 DOM mount point ownership
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
this.storage.get('volume'); // reads 'nmplayer-<plugin-id>-volume'
this.storage.set('volume', 0.8);
3.5 Plugin-scoped logger
this.logger.debug('something'); // outputs [nmplayer][my-plugin] something
4. Naming conventions
Method naming
| Pattern | Example |
|---|---|
| Stateful getter/setter overload | volume() reads, volume(v) writes |
| Action | play(), pause(), crossfadeTo() |
| State enum | playState(), shuffleState() |
| Boolean query | isLeader(), isMuted(), hasListeners() |
| Collection mutators | queueAppend, queueClear, queueRemove |
| Override hooks | protected 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
| Context | Convention | Example |
|---|---|---|
| Player core events | Bare name | play, pause, ready |
| Plugin events | plugin:<id>:<event> (auto-applied by this.emit) | plugin:equalizer:band:changed |
| Subsystem events | <subsystem>:<event> | backend:changed, auth:refreshed |
| Stage events | Bare camelCase | setupStart, pluginsRegistered |
| Lifecycle phase events | before<X> (pre) / bare <X> (post) | beforePlay, play |
Option key naming
- camelCase
- Boolean flags: positive form (
enabled, notdisabled) - 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 directpostMessage
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:
| Phase | Transitions to |
|---|---|
idle | setup |
setup | ready |
ready | loading, starting |
loading | ready |
starting | playing |
playing | buffering, seeking, paused |
buffering | playing, seeking |
seeking | playing, buffering |
paused | starting, ended, stopped |
ended | starting, disposing |
stopped | starting, disposing |
disposing | disposed |
disposed | — |
Listen to the phase event for transitions: { from: PlayerPhase; to: PlayerPhase }.
6.2 Dispatch context
player.dispatching() returns the active event chain:
[]['beforePlay'][('beforePlay', 'beforeMutation')]; // no event in flight // inside a beforePlay listener // nested
6.3 Plugin advisories
Declarative phase-aware contracts:
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
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 event | Effect on dependents |
|---|---|
| Dep never registered | addPlugin(dependent) throws synchronously |
Dep use() fails after dependent registered | Cascade-disable dependents |
Dep disable()’d | Cascade-disable dependents |
removePlugin(dep) with dependents registered | Throws unless { cascade: true } |
| Dep manually re-enabled | Dependents do NOT auto-re-enable |
8. Replacement and extension
Swap via same id + replaces
class CapacitorMediaSession extends Plugin {
static readonly id = 'media-session';
static readonly replaces = 'media-session';
}
Extend via subclass
class MyKeyHandler extends KeyHandlerPlugin {
static readonly id = 'key-handler';
protected addPlaybackKeys() {
this.bind('p', (player) => player.play());
}
}
Parameterize via derive()
const StrictMediaSession = MediaSessionPlugin.derive({ requireMetadata: true });
Override as last resort (tier 4)
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)
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
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 idis vendoredstatic readonly versionis a semver stringstatic readonly descriptionis a one-lineruse()anddispose()defined or inherited- All listeners use
this.on/this.listen(not rawplayer.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 declared and exported static requiresdeclared if depending on other plugins- Tests via
describePlugincover behavior +assertNoListenerLeak - No
eslint-disable-next-line nmplayer/...without explanation comment