Skip to content

Plugin Development: Video Player

Plugins for nomercy-video-player follow the same contract as the player core’s plugin system. This page covers video-player-specific patterns. For the full plugin authoring guide, see Plugin Authoring.

Built-in plugin catalog

All built-in plugins are importable from @nomercy-entertainment/nomercy-video-player/plugins.

PluginPlugin idWhat it does
DesktopUiPlugin'desktop-ui'Full controls overlay (progress bar, menus, activity hiding) for pointer devices
SubtitleOverlayPlugin'subtitle-overlay'Renders VTT subtitle cues as a positioned DOM overlay
OctopusPlugin'octopus'High-fidelity ASS/SSA subtitle rendering via libass/WASM
TouchZonesPlugin'touch-zones'Tap-zone overlay: double-tap to seek, single-tap to toggle playback
KeyHandlerPlugin'video-key-handler'Full keyboard binding set: playback, seek, volume, chapters, subtitles, speed
SkipperPlugin'skipper'Intro/recap/credits skip markers with optional auto-skip
DrmPlugin'drm'EME license acquisition for Widevine, FairPlay, and PlayReady
LiveTranscodingPlugin'live-transcoding'Server-side on-demand transcoding with WebSocket gating and backpressure
CastSenderPlugin'cast-sender'Chromecast CAF sender with session lifecycle and metadata mirroring
MediaSessionPlugin'media-session'Populates the OS “Now Playing” surface (lock screen, notifications)
TvKeyHandlerPlugin'tv-key-handler'TV remote bindings (Color buttons, Info OSD, MediaRecord bookmark, TV-aware Arrow seek and volume); subclasses KeyHandlerPlugin
TabLeaderPlugin'tab-leader'Web Locks-based single-tab playback enforcement across browser tabs
MessagePlugin'message'Toast and persistent overlay messages inside the player container
EmbedPlugin'embed'postMessage-based control API for players embedded in iframes
AudioGraphPlugin'audio-graph'Web Audio API signal chain, foundation for EQ, mixer, and spectrum plugins

Typing against the video player

Use IVideoPlayer as the P generic. IVideoPlayer is the stable public interface for the video player — it is the correct type for plugins and consumer functions. NMVideoPlayer is the concrete class (implementation detail); prefer IVideoPlayer so your plugin does not depend on the class hierarchy.

When a plugin needs to read the active item with this.player.item(), add & WithCurrentItem<YourItemType> to the P generic. Plugins that only call transport methods or metrics() can omit it — those are on IPlayer directly.

TypeScript
import { Plugin } from '@nomercy-entertainment/nomercy-player-core';
import type {
IPlayer,
WithCurrentItem,
BaseEventMap,
} from '@nomercy-entertainment/nomercy-player-core';
import type { IVideoPlayer, VideoPlaylistItem } from '@nomercy-entertainment/nomercy-video-player';

interface WatchProgressOptions {
reportUrl: string;
saveIntervalMs?: number; // default 5000
}

interface WatchProgressEvents {
'progress:saved': { itemId: string; timestamp: number; percentage: number };
}

export class WatchProgressPlugin extends Plugin<
IVideoPlayer & WithCurrentItem<VideoPlaylistItem>,
WatchProgressOptions,
WatchProgressEvents
> {
static readonly id = 'nomercy:watch-progress';
static readonly version = '1.0.0';
static readonly description = 'Persists watch progress to the NoMercy server';

use(): void {
this.on('progress', ({ time, percentage }) => {
this.saveProgress(time, percentage);
});
}

dispose(): void {}

private async saveProgress(time: number, percentage: number): Promise<void> {
const item = this.player.item(); // VideoPlaylistItem
if (!item) return;

const snapshot = this.player.metrics(); // PlaybackMetrics — no constraint needed

await this.fetch(this.opts.reportUrl, {
method: 'POST',
responseType: 'json',
body: JSON.stringify({ id: item.id, timestamp: Date.now(), percentage }),
});

this.emit('progress:saved', {
itemId: String(item.id),
timestamp: Date.now(),
percentage,
});
}
}

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

Accessing video-specific methods

Inside a plugin, this.player is typed as IVideoPlayer. Video-specific methods are available directly:

TypeScript
use(): void {
this.on('current', ({ item }) => {
if (!item) return;
const subtitles = this.player.subtitles();
const audioTracks = this.player.audioTracks();
const quality = this.player.quality();
this.reportTrackAvailability(subtitles.length, audioTracks.length);
});
}

Listening to video-specific events

TypeScript
use(): void {
// Quality level changes:
this.on('level-switched', ({ level }) => {
this.logQualityChange(level);
});

// Subtitle cue stream, for custom rendering:
this.on('subtitleCue', ({ cues }) => {
this.renderCues(cues);
});

// Chapter changes:
this.on('chapter', ({ index, title }) => {
this.updateChapterDisplay(index, title);
});

// Back button pressed (UI plugin fires this) — handle in the consumer, not here.
// Wire 'back' on the player object from your Vue component or app router, e.g.:
// player.on('back', () => router.back());
}

Extending a built-in plugin

Override DesktopUiPlugin’s key bindings without replacing the whole plugin:

TypeScript
import { KeyHandlerPlugin } from '@nomercy-entertainment/nomercy-video-player/plugins';

class MyKeyHandler extends KeyHandlerPlugin {
static readonly id = 'video-key-handler'; // same id = replaces the built-in

protected override addPlaybackKeys(): void {
super.addPlaybackKeys();
// Add custom binding: 'n' for next episode
this.bind('n', (player) => player.next({ source: 'user' }));
}
}

player.addPlugin(MyKeyHandler);

Video playlist item type extension

Extend VideoPlaylistItem with your own fields. The type threads through item(), queue(), and beforeLoad:

TypeScript
interface MyItem extends VideoPlaylistItem {
tmdbId: number;
rating: 'G' | 'PG' | 'R';
}

const player = nmplayer<MyItem>('main').setup({ ... });

// Inside a plugin typed against IVideoPlayer<MyItem>:
this.on('current', ({ item }) => {
if (item) {
console.log(item.tmdbId); // typed, no cast
console.log(item.rating); // typed
}
});

Best practices

Memory-safe cleanup with dispose()

The lifecycle helpers (this.on, this.listen, this.timeout, this.interval, this.frame, this.websocket, this.mount) all register automatic cleanup when the plugin is torn down. You only need dispose() for resources the kit cannot track — third-party library instances, custom WebGL contexts, non-standard observers.

TypeScript
export class ResponsiveOverlayPlugin extends Plugin<IVideoPlayer> {
static readonly id = 'nomercy:responsive-overlay';

private resizeObserver: ResizeObserver | null = null;

use(): void {
// mount() is auto-removed on dispose -- no manual cleanup needed
const overlay = this.mount('responsive-overlay');

// on() is auto-cleaned -- no stored reference or manual off() needed
this.on('current', ({ item }) => {
overlay.textContent = item?.title ?? '';
});

// ResizeObserver is not one of the tracked helpers, so we own its teardown.
this.resizeObserver = new ResizeObserver(() => {
overlay.dataset.size = this.player.container.clientWidth < 640 ? 'compact' : 'full';
});
this.resizeObserver.observe(this.player.container);
}

dispose(): void {
// Only clean up what the lifecycle cannot track: the ResizeObserver
this.resizeObserver?.disconnect();
this.resizeObserver = null;
}
}

Use this.listen() instead of addEventListener

this.listen(target, event, handler) wraps addEventListener and auto-removes on dispose. Raw addEventListener calls require manual tracking and are a common source of ghost listeners after the player is destroyed.

TypeScript
use(): void {
// Correct -- auto-removed on dispose
this.listen(document, 'keydown', this.handleKey);
this.listen(this.player.container, 'mousemove', this.handleMove);

// Wrong -- never use raw addEventListener inside a plugin
// document.addEventListener('keydown', this.handleKey);
}

Batch high-frequency time updates with this.frame()

The 'time' event fires on every HTMLVideoElement timeupdate, which can reach 25 Hz during playback. Driving DOM mutations directly from that handler causes layout thrashing. Use this.frame() to synchronise writes with the browser’s paint cycle instead — the kit drives the loop and cancels it on dispose.

TypeScript
private currentTime = 0;

use(): void {
this.on('time', ({ time }) => {
this.currentTime = time; // cheap write to memory only
});

this.frame(() => {
// Runs once per animation frame -- batches all DOM writes
const pct = (this.currentTime / this.player.duration()) * 100;
this.progressEl.style.width = `${pct}%`;
});
}

For a one-off deferred update (not a continuous loop), use this.timeout(fn, 0) instead.

Do not rely on DOM element order

Other plugins can insert or remove elements from the player container at any time. Query your own elements by the class name mount() assigned them rather than positional selectors like firstChild or querySelector('div:nth-child(2)').

TypeScript
use(): void {
// mount() returns the div it created -- hold the reference directly
const panel = this.mount('info-panel');
const title = document.createElement('h2');
panel.appendChild(title);

this.on('current', ({ item }) => {
// Query within your own mount point, not the whole container
panel.querySelector('h2')!.textContent = item?.title ?? '';
});
}

Prefer container CSS classes over JS for state-driven visibility

The player keeps a set of CSS state classes on this.player.container in sync with playback — the play state (playing / paused / buffering / …) plus active / inactive as the viewer moves or goes idle. Keying visibility on those classes with CSS is cheaper than toggling styles from event handlers and avoids the class of bugs where a handler fires before the DOM is ready.

CSS
/* plugin stylesheet */
.nmplayer-myui-controls {
opacity: 0;
transition: opacity 0.25s;
}

.nomercyplayer.active .nmplayer-myui-controls,
.nomercyplayer.paused .nmplayer-myui-controls {
opacity: 1;
}
TypeScript
use(): void {
const controls = this.mount('controls');
// No JS visibility toggling needed -- CSS handles it
}

See Plugin Authoring for the full list of container state classes.

Inter-plugin communication via events, not direct references

Use this.emit() to broadcast from a plugin and this.on(PluginClass, event, fn) to receive from a specific plugin. This keeps plugins decoupled and independently testable. Direct references (calling methods on another plugin instance) create hidden load-order dependencies.

TypeScript
// Emitting plugin
interface ChapterMenuEvents {
selected: { index: number; title: string };
}

export class ChapterMenuPlugin extends Plugin<IVideoPlayer, unknown, ChapterMenuEvents> {
static readonly id = 'nomercy:chapter-menu';

private selectChapter(index: number, title: string): void {
this.player.seekToChapter(index);
this.emit('selected', { index, title }); // scoped to plugin:nomercy:chapter-menu:selected
}
}

// Receiving plugin
export class TimelinePlugin extends Plugin<IVideoPlayer> {
static readonly id = 'nomercy:timeline';
static readonly requires = [ChapterMenuPlugin];

use(): void {
// on(PluginClass, event, fn) -- auto-cleaned, fully typed
this.on(ChapterMenuPlugin, 'selected', ({ index }) => {
this.highlightMarker(index);
});
}
}

Note: this.on(PluginClass, event, fn) is the typed form for listening to another plugin’s events. The consumer-facing string form is on('plugin:<id>:<event>', fn) on the player object itself — use the class form inside plugins.

Full plugin standard

See Plugin Authoring for the complete contract: required static fields, lifecycle methods, error handling via this.throw/this.report, event namespacing, dependency declarations, advisories, and the conformance checklist.