Skip to content

Custom Plugin

Building a production plugin: one that ships to users, runs in multiple player instances, or coordinates across components. The Plugin Authoring page covers the basics. This page covers the hard parts.

The dispose contract

Every resource you acquire in use() must be released in dispose(), or through a managed helper. The player core provides helpers that auto-dispose so you don’t have to track them manually:

ResourceManaged helperManual alternative
Event listenersthis.on(...)this.off(...) in dispose()
Timersthis.timeout(fn, ms)clearTimeout(id) in dispose()
Intervalsthis.interval(fn, ms)clearInterval(id) in dispose()
Animation framesthis.frame(fn)cancelAnimationFrame(id) in dispose()
DOM nodesthis.mount(className)el.remove() in dispose()
External resourcesNone, you must clean up manuallyWebSocket, Worker, etc.

If you create a WebSocket or Worker inside a plugin, close/terminate it in dispose():

TypeScript
export class MyPlugin extends Plugin<NMVideoPlayer, MyOptions, {}> {
static readonly id = 'myapp:my-plugin';
static readonly version = '1.0.0';
static readonly description = 'Example plugin';

private ws: WebSocket | null = null;

async use(): Promise<void> {
this.ws = new WebSocket(this.opts.wsUrl);
this.ws.onmessage = (event) => this.handleMessage(event);
// Register player events with this.on, auto-disposes:
this.on('current', ({ item }) => this.onTrackChange(item));
}

dispose(): void {
// Close the WebSocket manually, not tracked by the core:
this.ws?.close();
this.ws = null;
// this.on listeners are auto-disposed, no manual cleanup needed
}
}

State management

Keep plugin state private and expose only what consumers need:

TypeScript
export class PlaybackRatePlugin extends Plugin<
NMVideoPlayer,
PlaybackRateOptions,
PlaybackRateEvents
> {
static readonly id = 'myapp:playback-rate';
static readonly version = '1.0.0';
static readonly description = 'Variable playback speed';

// Private state:
private currentRate = 1.0;

async use(): Promise<void> {
// Restore saved rate from storage:
const saved = await this.storage.get('playback-rate');
if (saved) {
this.setRate(parseFloat(saved));
}
}

dispose(): void {}

// Public API, getter/setter pattern mirrors the player core:
rate(): number;
rate(value: number): this;
rate(value?: number): number | this {
if (value === undefined) return this.currentRate;
this.setRate(value);
return this;
}

private setRate(rate: number): void {
this.currentRate = Math.max(0.25, Math.min(4.0, rate));
this.player.container
.querySelector('video')
?.setAttribute('playbackRate', String(this.currentRate));
this.storage.set('playback-rate', String(this.currentRate));
this.emit('rateChanged', { rate: this.currentRate });
}
}

interface PlaybackRateEvents {
rateChanged: { rate: number };
}

Multi-instance safety

When multiple player instances exist on the same page, each plugin instance is isolated: this.storage, this.on, this.emit, and this.player are all scoped to their own player. There is no shared mutable state between instances unless you introduce it.

Be careful with singletons:

TypeScript
// Bad, global mutable singleton shared across all instances:
const globalState = { activePlayer: null as NMVideoPlayer | null };
// Good:
const globalState: { activePlayer: NMVideoPlayer | null } = { activePlayer: null };

// Good, state is per-instance via this:
class MyPlugin extends Plugin<...> {
private localState = { active: false };
}

If you need cross-instance coordination (for example, pausing all others when one starts), use the realtime channel adapter or a shared event bus:

TypeScript
// Simplified tab-broadcast example using BroadcastChannel:
class ExclusivePlayPlugin extends Plugin<NMVideoPlayer, {}, {}> {
static readonly id = 'myapp:exclusive-play';
static readonly version = '1.0.0';
static readonly description = 'Pause other instances when this one starts';

private channel: BroadcastChannel | null = null;

use(): void {
this.channel = new BroadcastChannel('nomercy-exclusive-play');

// When this player starts, broadcast to others:
this.on('play', () => {
this.channel?.postMessage({ playerId: this.player.id });
});

// When another player starts, pause this one:
this.channel.onmessage = (event) => {
if (event.data.playerId !== this.player.id) {
this.player.pause();
}
};
}

dispose(): void {
this.channel?.close();
this.channel = null;
}
}

Phase advisories

Advisories let the player core flag a method called at the wrong lifecycle phase:

TypeScript
static readonly advisories: PluginAdvisory[] = [
{
method: 'rate', // name of the method
duringPhase: ['idle'], // phases where this call is a mistake
severity: 'warning',
reason: 'no-item-loaded',
message: 'Setting playback rate before any item is loaded has no effect.',
},
];

When a guarded mutation matches, the player emits the advisory’s severity event (info / warning / error) on the player — in all environments. It is an event, not a console-only warning, and it is not gated on NODE_ENV.

Static onError recovery

Declare what happens when specific error codes are emitted by the player or a dependency:

TypeScript
static readonly onError: Record<string, PluginRecoveryAction> = {
'core:auth/unauthenticated': 'disable',
'core:stream/segment-load-error': 'ignore',
};

Options: 'retry-once' | 'fallback' | 'disable' | 'ignore'.

Worked example: watch-party-lite plugin

A plugin that syncs play/pause and seek position across browser tabs using BroadcastChannel:

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

interface WatchPartyOptions {
roomId: string; // unique room identifier, all tabs with the same roomId sync
role?: 'leader' | 'follower'; // 'leader' emits, 'follower' obeys. Default: 'leader'
}

interface WatchPartyEvents {
synced: { action: 'play' | 'pause' | 'seek'; time: number };
roleChanged: { role: 'leader' | 'follower' };
}

export class WatchPartyLitePlugin extends Plugin<
NMVideoPlayer,
WatchPartyOptions,
WatchPartyEvents
> {
static readonly id = 'myapp:watch-party-lite';
static readonly version = '1.0.0';
static readonly description = 'Sync play/pause/seek across browser tabs';

private channel: BroadcastChannel | null = null;
private ignoreNextEvent = false;

use(): void {
const role = this.opts.role ?? 'leader';
this.channel = new BroadcastChannel(`watch-party-${this.opts.roomId}`);

if (role === 'leader') {
// Leader broadcasts state changes to the channel:
this.on('play', () => this.broadcast('play'));
this.on('pause', () => this.broadcast('pause'));
this.on('seeked', () => this.broadcast('seek'));
}

// All instances (including leader) receive and apply remote events:
this.channel.onmessage = (event) => {
if (role === 'follower') {
this.applyRemoteEvent(event.data);
}
};
}

dispose(): void {
this.channel?.close();
this.channel = null;
}

private broadcast(action: 'play' | 'pause' | 'seek'): void {
if (this.ignoreNextEvent) {
this.ignoreNextEvent = false;
return;
}
const time = this.player.time();
this.channel?.postMessage({ action, time });
this.emit('synced', { action, time });
}

private applyRemoteEvent(data: { action: 'play' | 'pause' | 'seek'; time: number }): void {
this.ignoreNextEvent = true;

if (data.action === 'play') {
this.player.time(data.time); // sync position first
this.player.play();
} else if (data.action === 'pause') {
this.player.time(data.time);
this.player.pause();
} else if (data.action === 'seek') {
this.player.time(data.time);
}

this.emit('synced', { action: data.action, time: data.time });
}
}

Usage:

TypeScript
// Tab 1 (leader):
player.addPlugin(WatchPartyLitePlugin, { roomId: 'movie-night-42', role: 'leader' });

// Tab 2 (follower):
player.addPlugin(WatchPartyLitePlugin, { roomId: 'movie-night-42', role: 'follower' });

Testing a complex plugin

TypeScript
import { describePlugin } from '@nomercy-entertainment/nomercy-player-core/testing';
import { WatchPartyLitePlugin } from './watch-party-lite';

describePlugin(
WatchPartyLitePlugin,
({ player, plugin }) => {
test('broadcasts play events', () => {
const synced: unknown[] = [];
player.on('plugin:myapp:watch-party-lite:synced', (event) => synced.push(event));

player.emit('play');

expect(synced).toHaveLength(1);
expect(synced[0]).toMatchObject({ action: 'play' });
});
},
{ opts: { roomId: 'test-room', role: 'leader' } },
);