Migration Guide: nomercy-video-player v1 to v2
This guide covers every breaking change between @nomercy-entertainment/nomercy-video-player v1 and v2.
Read it before upgrading.
For core-level changes (subpath imports, adapter ports, five-layer architecture), see the core migration guide.
Fast path: compat plugin
If you want your existing v1 code running immediately while you migrate gradually, register V1VideoCompatPlugin right after constructing the player.
npm install @nomercy-entertainment/nomercy-video-player@beta
import nmplayer from '@nomercy-entertainment/nomercy-video-player';
import { V1VideoCompatPlugin } from '@nomercy-entertainment/nomercy-video-player/plugins/v1-compat';
const player = nmplayer('player-1');
player.addPlugin(V1VideoCompatPlugin);
await player.setup({ /* your existing config */ });
// Your existing v1 code keeps working:
player.setPlaylist(items);
player.playVideo(0);
player.on('item', (item) => console.log(item.title));
The browser console will show a deprecation warning for each v1 API you use, naming the exact v2 replacement. Migrate one call site at a time, guided by those warnings. When they stop, remove the plugin.
The compat plugin is temporary and ships as a separate subpath so it never adds weight to new projects. See the compat plugin reference for the complete method mapping, event bridge table, and unshimmable APIs.
Manual migration
The rest of this page covers every breaking change for teams that prefer a planned, single-pass upgrade.
TL;DR: is my import broken?
The npm package name is the same: @nomercy-entertainment/nomercy-video-player.
The import { nmplayer, NMVideoPlayer } resolves identically.
If you are on npm ^1.x you will not automatically receive v2, so you must opt in with ^2.0.0.
Your code will break.
Every player.on(...) call needs updating because event payload shapes changed across the board.
Method names changed.
Playlist item field names changed.
The plugin API is a full replacement.
Read the checklist below, then work through each section.
Quick migration checklist
- Replace
player.seek(t)withplayer.time(t) - Replace
player.speed(v)/player.speeds()withplayer.playbackRate(v)/player.playbackRates() - Replace
player.muted(bool)withplayer.mute()/player.unmute() - Replace
player.quality(idx)with the v2player.quality(idx)— the name returned to the bare-noun form - Replace
player.audioTrack(idx)with the v2player.audioTrack(idx)— the name returned to the bare-noun form - Replace
player.subtitle(idx)with the v2player.subtitle(idx)— the name returned to the bare-noun form - Replace
player.playlist()withplayer.queue(),player.setPlaylist(items)withplayer.queue(items) - Replace
player.playVideo(idx)withplayer.seekToIndex(idx + 1)—seekToIndexis 1-based;playVideo(0)becomesseekToIndex(1) - Replace
player.fetchPlaylist(url)withplayer.loadQueue(url, parser?) - Replace
player.element()withplayer.container - Replace
player.state()withplayer.playState() - Replace
player.registerPlugin(name, inst)withplayer.addPlugin(PluginClass, opts?) - Replace
player.usePlugin(name), plugins activate automatically inaddPlugin() - Replace
player.plugin(name)withplayer.getPluginById(id) - Replace
player.localize(key)withplayer.t(key, vars?) - Replace
player.getAccessToken()withplayer.auth()?.bearerToken - Replace
player.setAccessToken(t)withplayer.auth({ bearerToken: t }) - Replace
item.filewithitem.urlon every playlist item (see silent-break risk) - Replace
item.durationstring withitem.durationnumber (was formatted string, now seconds) - Update every
player.on(...)event handler, payload shapes changed for all events - Remove calls to
hasSpeeds(),hasQualities(),hasAudioTracks(),hasSubtitles(), derive from lengths instead - Remove calls to
setEpisode(s, e), usequeue(items)+item(targetItem)instead
Events renamed
| v1 event | v2 event |
|---|---|
item | current |
playlist | queue |
playlistComplete | queue:exhausted |
complete | queue:exhausted |
subtitleChanged | subtitle |
subtitles (cue data) | subtitleCue |
levelsChanged | level-switched |
speed | (removed, use playbackRate event instead) |
Events removed
These events no longer exist in v2. No compatibility shim is provided.
| v1 event | Why removed | v2 replacement |
|---|---|---|
speed | playbackRate event covers it | Listen to playbackRate |
captionsChanged | Deprecated in late v1 | Listen to subtitle |
captionsList | Deprecated in late v1 | Listen to subtitle (index change) |
visualQuality | Internal hls.js detail, leaked publicly | No replacement, backend-internal |
audioTrackChanged | Superseded by payload on audioTracks | Filter audioTracks event |
hls | Was never a real event; hls.js internal | No replacement |
active | UI plugin concern | Desktop UI plugin |
controls / showControls / hideControls | Deprecated in v1 | Desktop UI plugin |
theaterMode | Deprecated alias | theater event |
pip-internal | Internal detail | pip event |
interaction | UI concern | Desktop UI plugin |
player-click | UI concern | Desktop UI plugin |
fonts | Backend-internal now | No replacement |
Events: payload shapes changed
Every event that carried a raw element or a complex multi-field object now carries a typed wrapper object.
Update every player.on(...) call.
| v1 event | v1 payload | v2 payload |
|---|---|---|
play | TimeData | ActionOptions (check source field for what triggered it) |
pause | TimeData | ActionOptions |
time | TimeData (8 fields) | { time: number } |
seek | TimeData | { time: number; source?: string } |
seeked | TimeData | { time: number } |
duration | TimeData | { duration: number } |
current (was item) | PlaylistItem | { item: T; index: number } |
queue (was playlist) | PlaylistItem[] | BasePlaylistItem[] |
error | MediaError | undefined | PlayerErrorEvent |
warning | string | PlayerErrorEvent |
volume | VolumeState object | { level: number } |
mute | VolumeState object | { muted: boolean } |
levels | Level[] | { levels: QualityLevel[] } |
level-switched (was levelsChanged) | CurrentTrack | { level: number } |
audioTracks | AudioTrack[] | { tracks: AudioTrack[] } |
subtitle (was subtitleChanged) | SubtitleTrack | undefined | { track: number | null } |
subtitleCue | (new) | SubtitleCueChange |
fullscreen | boolean | { active: boolean } |
pip | boolean | { active: boolean } |
theater | boolean | { active: boolean } |
waiting | HTMLVideoElement | void |
canplay | HTMLVideoElement | void |
Methods renamed
| v1 method | v2 method |
|---|---|
seek(t) | time(t, opts?) |
speed(v?) | playbackRate(r?) |
speeds() | playbackRates() |
muted(v?) | mute() / unmute() / toggleMute() |
quality(idx?) | quality(idx?) (same bare-noun form retained) |
audioTrack(idx?) | audioTrack(idx?) (same bare-noun form retained) |
audioTrackIndex() | audioTrack() (getter overload) |
subtitle(idx?) | subtitle(idx?) (same bare-noun form retained) |
subtitleIndex() | subtitle() (getter overload) |
chapter(time) | chapter() |
fullscreen(v?) | fullscreen(v?) |
enterFullscreen() | fullscreen(true) or toggleFullscreen() |
exitFullscreen() | fullscreen(false) |
pip(v?) | pip(v?) |
theater(v?) | theater(v?) |
aspect(v?) | aspectRatio(v?) |
playlist() | queue() |
setPlaylist(items) | queue(items) |
load(items) | queue(items) |
fetchPlaylist(url) | loadQueue(url, parser?) |
playVideo(idx) | seekToIndex(idx + 1, opts?) — see index convention note below |
playlistItem(idx?) | item() / seekToIndex(idx + 1) |
playlistIndex() | index() |
state() | playState() |
element() | player.container (property) |
buffer() | bufferedRanges() |
timeData() | timeData() (payload shape changed) |
registerPlugin(name, inst) | addPlugin(PluginClass, opts?) |
usePlugin(name) | (automatic on addPlugin) |
plugin(name) | getPluginById(id) |
getAccessToken() | player.auth()?.bearerToken |
setAccessToken(t) | player.auth({ bearerToken: t }) |
localize(key) | player.t(key, vars?) |
setup(opts) | setup(opts), signature completely changed (see config diff below) |
Methods removed
These methods have no direct v2 replacement. Use the alternatives described.
| v1 method | Alternative |
|---|---|
hasSpeeds() | player.playbackRates().length > 1 |
hasQualities() | player.qualityLevels().length > 0 |
hasAudioTracks() | player.audioTracks().length > 1 |
hasSubtitles() | player.subtitles().length > 0 |
hdrSupported() | Backend-internal; use player.canPlay(profile) |
setEpisode(season, ep) | Find item in player.queue(), call player.item(item) |
isFirstPlaylistItem() | player.index() === 0 |
isLastPlaylistItem() | player.index() === player.queueLength() - 1 |
hasPlaylists() | player.queueLength() > 1 |
seasons() | Derive from player.queue() grouped by item.season |
tracks(kind?) | Call player.subtitles(), player.audioTracks(), player.chapters() separately |
setPlaylistItemCallback(fn) | Listen to the beforeLoad cancellable event |
storeSubtitleChoice() | Consumer or plugin responsibility |
setCurrentAudioTrackFromStorage() | Consumer or plugin responsibility |
setCurrentCaptionFromStorage() | Consumer or plugin responsibility |
subtitleIndexBy(lang, type, ext) | Filter player.subtitles() by your criteria |
loadSource(url) | Use player.load(item) with a full item object |
setConfig(opts) | Use typed methods: auth(), baseUrl(), volume(), etc. |
setTitle(text) | Consumer concern, do not call player for this |
displayMessage(msg) | player.getPlugin(MessagePlugin).show(msg) |
currentSrc() | player.item()?.url |
skippers() | player.getPlugin(SkipperPlugin).skippers() |
skip() | player.getPlugin(SkipperPlugin).skip(kind?) |
gain(v?) | player.getPlugin(AudioGraphPlugin) |
addGainNode() | AudioGraphPlugin |
removeGainNode() | AudioGraphPlugin.dispose() |
setMediaAPI() | player.addPlugin(MediaSessionPlugin) |
hls (property) | Backend-internal; no public access path |
fetchChapterFile() | Backend-driven through IChapterSource adapter |
fetchSubtitleFile() | Backend-driven through cue parser registry |
buildSubtitleFragment() | SubtitleOverlayPlugin handles rendering |
float(v?) | Consumer viewport concern, use IntersectionObserver in your app |
ui_addActiveClass() / ui_removeActiveClass() | DesktopUiPlugin |
ui_resetInactivityTimer() | DesktopUiPlugin |
resize() | Consumer or plugin concern |
hdrSupported() | Backend-internal |
item.file → item.url
This is the highest-risk silent break in the migration.
v1 PlaylistItem used file: string as the video source URL.
v2 VideoPlaylistItem uses url?: string.
If your server API returns playlist items with a file field and you pass them to the player without mapping, the player will silently receive undefined for the source URL and fail to load.
// v1: server returns { file: '...', title: '...' }
// @ts-expect-error — setPlaylist does not exist in v2
player.setPlaylist(serverItems); // worked because player read item.file
// v2: BREAKS. item.url is undefined, player cannot load the source.
player.queue(serverItems);
// v2: correct. Map the field before passing to the player:
player.queue(serverItems.map((item) => ({ ...item, url: item.file })));
Coordinate with the server team: the cleanest fix is to update the server API response to emit url instead of file.
Until then, map at the call site in your adapter layer.
Additional PlaylistItem shape changes:
| v1 field | v2 field | Change |
|---|---|---|
file: string (required) | url?: string | Renamed + now optional |
duration: string (formatted, e.g. "1:24:36") | duration?: number (seconds) | Type changed: "1:24:36" becomes NaN in v2 |
image: string (required) | poster?: string | Renamed + optional |
description: string (required) | (removed) | No equivalent |
year?: number | (removed) | No equivalent |
uuid?: string | (removed) | No equivalent |
seasonName?: string | (removed) | No equivalent |
progress: { time, date } | progress?: { timestamp, percentage } | Reshaped: time+date to timestamp+percentage |
tracks[{ kind:'subtitles' }] | subtitles?: SubtitleTrackRef[] | Now a typed top-level field |
tracks[{ kind:'chapters' }] | chapters?: ChapterRef[] | Now a typed top-level field |
tracks[{ kind:'skippers' }] | skippers?: { intro?, recap?, credits? } | Format changed, see below |
tracks[{ kind:'thumbnails' }] | previewSpriteUrl?: string | Promoted to a top-level string field |
duration type change is high risk.
Any component passing item.duration to a formatter (e.g. to display "1:24:36") will receive a number instead and produce incorrect output.
Update formatters to accept number (seconds).
progress shape change affects continue-watching.
If your UI reads item.progress.time to restore position, update to item.progress.timestamp.
skippers format changed.
v1 expected a kind='skippers' sidecar VTT file reference in tracks[].
v2 SkipperPlugin reads item.skippers: { intro?: TimeRange, recap?: TimeRange, credits?: TimeRange }.
Either update the server response or register a custom cue parser that bridges the VTT format to the structured object.
PlaylistItem generic parameter
NMVideoPlayer<T extends VideoPlaylistItem> is generic in v2.
Your playlist item type is threaded through the player’s type system, so player.item() returns T | undefined rather than VideoPlaylistItem | undefined.
interface MyPlaylistItem extends VideoPlaylistItem {
internalId: string;
rating: number;
}
const player = nmplayer<MyPlaylistItem>('player-1').setup({ ... });
// player.item() is typed as MyPlaylistItem | undefined
const item = player.item();
item?.internalId; // typed, no cast needed
seekToIndex is 1-based
Index convention changed.
playVideo(0)in v1 loaded the first item. The v2 equivalent isseekToIndex(1).
seekToIndex uses a 1-based ordinal: seekToIndex(1) is the first item, seekToIndex(queueLength()) is the last.
Passing 0 or any non-positive integer throws RangeError.
// v1 — 0-based
player.playVideo(0); // first item
player.playVideo(2); // third item
// v2 — 1-based
player.seekToIndex(1); // first item
player.seekToIndex(3); // third item
player.seekToIndex(0); // RangeError — never pass 0
Any v1 code that passes a loop index directly to playVideo(i) must be updated to seekToIndex(i + 1).
Subtitle and quality index convention
v1’s subtitle(idx) and quality(idx) setters accepted index values where -1 meant “disable subtitles” or “auto quality”.
In v2 these getters return a selection object, not a raw index:
quality() returns { index, track } for the selected level, or the string 'auto' when ABR is active.
subtitle() returns { index, track } for the active track, or null when off.
ready event timing
v1 fired ready after a hardcoded timeout (implementation detail).
v2 fires ready after the full setup pipeline resolves, including all plugins’ async use() promises.
Code that depended on the v1 timing must not assume any specific delay.
If you need to act after the player and all plugins’ use() has resolved:
await player.ready();
// or
player.on('ready', () => {
/* all plugins resolved */
});
Plugin system replacement
v1 used a string-keyed instance registry:
// v1
// @ts-expect-error — registerPlugin does not exist in v2
player.registerPlugin('octopus', new OctopusPlugin({ ... }));
// @ts-expect-error — usePlugin does not exist in v2
player.usePlugin('octopus');
// @ts-expect-error — plugin() does not exist in v2
const plugin = player.plugin('octopus');
v2 uses a class-based factory:
// v2
player.addPlugin(OctopusPlugin, {
/* options */
});
const plugin = player.getPlugin(OctopusPlugin); // typed return
Key differences:
addPlugin(Cls, opts)replaces bothregisterPluginandusePlugin, the plugin activates immediatelygetPlugin(Cls)returns the typed plugin instance; no string key, no cast- Plugin options are passed at registration, not at construction
- Plugins that depend on other plugins declare
static requires = [OtherPluginClass]; the player enforces registration order - Cross-plugin event listening: a plugin uses the protected
this.on(PluginClass, eventName, fn)(typed); a consumer listens via the namespaced string formplayer.on('plugin:<id>:<event>', fn)
Built-in plugins that moved from always-on to opt-in:
| v1 (always on) | v2 (opt-in, add explicitly) |
|---|---|
OctopusPlugin (was registered by default in some setups) | player.addPlugin(OctopusPlugin, { ... }) |
playerUIPlugin (always mounted) | player.addPlugin(DesktopUiPlugin) |
MessagePlugin (always mounted) | player.addPlugin(MessagePlugin) |
KeyHandlerPlugin (always mounted) | player.addPlugin(KeyHandlerPlugin) |
| MediaSession (built into player core) | player.addPlugin(MediaSessionPlugin) |
Plugin renamed: playerUIPlugin → DesktopUiPlugin
Adapter ports
v2 introduces a set of named adapter ports (the core ports plus a few video-specific ones).
All ports have sensible defaults.
You only need to wire an adapter through setup() when you want to replace the default behavior.
import { nmplayer, Html5VideoBackend } from '@nomercy-entertainment/nomercy-video-player';
import { browserPlatform, LocalStorageBackend } from '@nomercy-entertainment/nomercy-player-core';
const player = nmplayer('player-1').setup({
// Injecting core adapters:
platform: browserPlatform,
storage: new LocalStorageBackend(),
auth: { bearerToken: () => myAuth.getToken() },
});
For native-shell environments (Capacitor, Tauri, Electron), override individual platform sub-ports without rebuilding the entire platform bundle:
player.setup({
platform: { ...browserPlatform, wakeLock: myNativeWakeLock },
});
See the core migration guide for the full port catalog and subpath import table.
Subpath imports
v2 exports each adapter and plugin from a dedicated subpath, enabling tree-shaking. The root barrel import still works; subpaths are opt-in.
// All plugins via the /plugins barrel:
import { OctopusPlugin, DesktopUiPlugin } from '@nomercy-entertainment/nomercy-video-player/plugins';
// Tree-shakeable per-plugin subpaths:
import { OctopusPlugin } from '@nomercy-entertainment/nomercy-video-player/plugins/octopus';
import { DesktopUiPlugin } from '@nomercy-entertainment/nomercy-video-player/plugins/desktop-ui';
import { Html5VideoBackend, VttChapterSource, VttSpriteThumbnailSource, StorageBackedSubtitleStyleStore } from '@nomercy-entertainment/nomercy-video-player';
Configuration shape changes
Renamed config fields
| v1 field | v2 field |
|---|---|
accessToken | auth: { bearerToken: ... } |
basePath | (removed, use baseUrl) |
displayLanguage | language |
customStorage | storage |
log | logLevel + logger |
controlsTimeout | Desktop UI plugin option |
doubleClickDelay | Touch Zones plugin option |
Removed config fields
| v1 field | Reason |
|---|---|
disableHls | Backend decision, use backendFactory |
forceHls | Backend decision, use backendFactory |
float | Consumer viewport concern |
pip (config object) | Replaced by pip() method + desktop UI plugin |
messagePlugin | Use addPlugin(MessagePlugin) |
disableTouchControls | Use addPlugin(TouchZonesPlugin) and disable |
chapters (boolean) | Always through IChapterSource adapter |
stretching: '16:9' / '4:3' | Use 'uniform' + player sizing |
New config fields
| v2 field | Description |
|---|---|
auth | Auth pipeline config: bearer token, refresh, request signing |
backendFactory | Inject a custom IVideoBackend |
defaultSubtitleLanguage | Language code for initial subtitle selection |
defaultAudioLanguage | Language code for initial audio track selection |
defaultQuality | 'auto' or a quality level index |
preloadLeadSeconds | Seconds ahead to preload |
cast | Cast config for CastSenderPlugin |
platform | Full platform abstraction bundle |
Upgrading downstream NoMercy projects
nomercy-app-web
High-impact areas:
- Every
player.on(...)event handler in the music and video views needs payload updates (all events) setEpisode(s, e)calls in the episode player need replacing withqueue(items)+item(item)navigationhasSpeeds(),hasQualities(),hasAudioTracks(),hasSubtitles()calls in control-visibility logic need replacing with.lengthchecksseasons()call in the series view needs replacing with a groupBy overplayer.queue()progress.time→progress.timestampin continue-watching logicitem.durationformatting: was string, now number in secondsitem.file→item.urlin the adapter layer that maps server responses to playlist items- Plugin registrations:
registerPlugin(name, inst)→addPlugin(Class, opts)for Octopus, key handler, and any UI plugins
nomercy-cast-player
The cast receiver is currently v1-based and is slated for a full rewrite. Migration to v2 should happen as part of that rewrite rather than as an incremental patch.
Getting help
- Issue tracker: github.com/NoMercy-Entertainment/nomercy-video-player/issues
- Discord: NoMercy Entertainment server,
#player-devchannel - Testbed (live integration reference):
tools/player-testbed/in the monorepo - Core adapter catalog: Core Adapters