Plugin Development: Video Player
Plugins for nomercy-video-player follow the same contract as the player core’s plugin system. This page covers the video-player-specific patterns. For the base plugin contract — every helper on this, the lifecycle, and the registration pipeline — see The Plugin Base and Player and Plugin.
Built-in plugin catalog
All built-in plugins are importable from @nomercy-entertainment/nomercy-video-player/plugins.
| Plugin | Plugin id | What 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 | 'key-handler' | Full keyboard binding set: playback, seek, volume, chapters, subtitles, speed |
TvKeyHandlerPlugin | 'tv-key-handler' | TV remote bindings; subclasses KeyHandlerPlugin |
DrmPlugin | 'drm' | EME license acquisition for Widevine, FairPlay, and PlayReady |
CastSenderPlugin | 'cast-sender' | Chromecast CAF sender with session lifecycle and metadata mirroring |
MediaSessionPlugin | 'media-session' | Populates the OS “Now Playing” surface (lock screen, notifications) |
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.
import { Plugin } from '@nomercy-entertainment/nomercy-player-core';
import type { WithCurrentItem } 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 }) => {
void this.saveProgress(time, percentage);
});
}
private async saveProgress(time: number, percentage: number): Promise<void> {
const item = this.player.item(); // VideoPlaylistItem
if (!item) return;
// this.fetch runs the player's auth pipeline and aborts on dispose —
// never the global fetch inside a plugin.
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:
use(): void {
this.on('item', ({ 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
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 (emitted by seekToChapter):
this.on('chapter', ({ index, title }) => {
this.updateChapterDisplay(index, title);
});
}
The 'back' event is fired by UI plugins and handled in the consumer, not a plugin. Wire it on the player object from your app: player.on('back', () => router.back()).
Extending a built-in plugin
Override a built-in’s bindings without replacing the whole plugin. A subclass that keeps the same static id replaces the built-in at registration:
import { KeyHandlerPlugin } from '@nomercy-entertainment/nomercy-video-player/plugins';
class MyKeyHandler extends KeyHandlerPlugin {
static override readonly id = '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:
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('item', ({ item }) => {
if (item) {
this.report(item.tmdbId); // typed, no cast
this.report(item.rating); // typed
}
});
Best practices
Memory-safe cleanup
The lifecycle helpers — this.on, this.listen, this.timeout, this.interval, this.frame, this.websocket, this.mount, and this.lifecycle.observe — all register automatic cleanup when the plugin is torn down. That is why most plugins need no dispose() at all: there is nothing left to release by hand. You override dispose() only for a resource none of the helpers track — a third-party library instance, a custom WebGL context.
A ResizeObserver is tracked: wrap it with this.lifecycle.observe, which disconnects it on dispose and returns the observer so you chain the observe(el) straight on:
export class ResponsiveOverlayPlugin extends Plugin<IVideoPlayer> {
static override readonly id = 'nomercy:responsive-overlay';
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('item', ({ item }) => {
overlay.textContent = item?.title ?? '';
});
// lifecycle.observe() disconnects the observer on dispose.
this.lifecycle
.observe(new ResizeObserver(() => {
overlay.dataset.size = this.player.container.clientWidth < 640 ? 'compact' : 'full';
}))
.observe(this.player.container);
}
}
A raw new ResizeObserver that you store and disconnect by hand works, but it is the pattern this.lifecycle.observe exists to remove — and the @nomercy-entertainment/eslint-plugin-player rules flag it.
Use this.listen() instead of addEventListener
this.listen(target, event, handler) wraps addEventListener and auto-removes on dispose. A raw addEventListener inside a plugin needs manual tracking and is a common source of ghost listeners after the player is destroyed — the lint rules flag it for the same reason.
use(): void {
// Correct — auto-removed on dispose
this.listen(document, 'keydown', this.handleKey);
this.listen(this.player.container, 'mousemove', this.handleMove);
}
Batch high-frequency time updates with this.frame()
The 'time' event fires on every timeupdate, which can reach 25 Hz during playback. Driving DOM mutations straight from that handler causes layout thrashing. Cache the value in the handler and let this.frame() synchronise writes with the browser’s paint cycle — the kit drives the loop and cancels it on dispose:
private currentTime = 0;
use(): void {
this.on('time', ({ position }) => {
this.currentTime = position; // 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)').
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('item', ({ 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.
/* plugin stylesheet */
.nmplayer-myui-controls {
opacity: 0;
transition: opacity 0.25s;
}
.nomercyplayer.active .nmplayer-myui-controls,
.nomercyplayer.paused .nmplayer-myui-controls {
opacity: 1;
}
use(): void {
const controls = this.mount('controls');
// No JS visibility toggling needed — CSS handles it
}
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.
// Emitting plugin
interface ChapterMenuEvents {
selected: { index: number; title: string };
}
export class ChapterMenuPlugin extends Plugin<IVideoPlayer, unknown, ChapterMenuEvents> {
static override 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 override readonly id = 'nomercy:timeline';
static override readonly requires = [ChapterMenuPlugin];
use(): void {
// on(PluginClass, event, fn) — auto-cleaned, fully typed
this.on(ChapterMenuPlugin, 'selected', ({ index }) => {
this.highlightMarker(index);
});
}
}
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.
Next steps
- The Plugin Base: every helper on
this, the lifecycle, and how registration wires a plugin to its player. - Player and Plugin: the
this-versus-this.playerboundary the lint rules enforce.