Skip to content

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.

Code
npm install @nomercy-entertainment/nomercy-video-player@beta
TypeScript
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) with player.time(t)
  • Replace player.speed(v) / player.speeds() with player.playbackRate(v) / player.playbackRates()
  • Replace player.muted(bool) with player.mute() / player.unmute()
  • Replace player.quality(idx) with the v2 player.quality(idx) — the name returned to the bare-noun form
  • Replace player.audioTrack(idx) with the v2 player.audioTrack(idx) — the name returned to the bare-noun form
  • Replace player.subtitle(idx) with the v2 player.subtitle(idx) — the name returned to the bare-noun form
  • Replace player.playlist() with player.queue(), player.setPlaylist(items) with player.queue(items)
  • Replace player.playVideo(idx) with player.seekToIndex(idx + 1)seekToIndex is 1-based; playVideo(0) becomes seekToIndex(1)
  • Replace player.fetchPlaylist(url) with player.loadQueue(url, parser?)
  • Replace player.element() with player.container
  • Replace player.state() with player.playState()
  • Replace player.registerPlugin(name, inst) with player.addPlugin(PluginClass, opts?)
  • Replace player.usePlugin(name), plugins activate automatically in addPlugin()
  • Replace player.plugin(name) with player.getPluginById(id)
  • Replace player.localize(key) with player.t(key, vars?)
  • Replace player.getAccessToken() with player.auth()?.bearerToken
  • Replace player.setAccessToken(t) with player.auth({ bearerToken: t })
  • Replace item.file with item.url on every playlist item (see silent-break risk)
  • Replace item.duration string with item.duration number (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), use queue(items) + item(targetItem) instead

Events renamed

v1 eventv2 event
itemcurrent
playlistqueue
playlistCompletequeue:exhausted
completequeue:exhausted
subtitleChangedsubtitle
subtitles (cue data)subtitleCue
levelsChangedlevel-switched
speed(removed, use playbackRate event instead)

Events removed

These events no longer exist in v2. No compatibility shim is provided.

v1 eventWhy removedv2 replacement
speedplaybackRate event covers itListen to playbackRate
captionsChangedDeprecated in late v1Listen to subtitle
captionsListDeprecated in late v1Listen to subtitle (index change)
visualQualityInternal hls.js detail, leaked publiclyNo replacement, backend-internal
audioTrackChangedSuperseded by payload on audioTracksFilter audioTracks event
hlsWas never a real event; hls.js internalNo replacement
activeUI plugin concernDesktop UI plugin
controls / showControls / hideControlsDeprecated in v1Desktop UI plugin
theaterModeDeprecated aliastheater event
pip-internalInternal detailpip event
interactionUI concernDesktop UI plugin
player-clickUI concernDesktop UI plugin
fontsBackend-internal nowNo 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 eventv1 payloadv2 payload
playTimeDataActionOptions (check source field for what triggered it)
pauseTimeDataActionOptions
timeTimeData (8 fields){ time: number }
seekTimeData{ time: number; source?: string }
seekedTimeData{ time: number }
durationTimeData{ duration: number }
current (was item)PlaylistItem{ item: T; index: number }
queue (was playlist)PlaylistItem[]BasePlaylistItem[]
errorMediaError | undefinedPlayerErrorEvent
warningstringPlayerErrorEvent
volumeVolumeState object{ level: number }
muteVolumeState object{ muted: boolean }
levelsLevel[]{ levels: QualityLevel[] }
level-switched (was levelsChanged)CurrentTrack{ level: number }
audioTracksAudioTrack[]{ tracks: AudioTrack[] }
subtitle (was subtitleChanged)SubtitleTrack | undefined{ track: number | null }
subtitleCue(new)SubtitleCueChange
fullscreenboolean{ active: boolean }
pipboolean{ active: boolean }
theaterboolean{ active: boolean }
waitingHTMLVideoElementvoid
canplayHTMLVideoElementvoid

Methods renamed

v1 methodv2 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 methodAlternative
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.fileitem.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.

TypeScript
// 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 fieldv2 fieldChange
file: string (required)url?: stringRenamed + 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?: stringRenamed + 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?: stringPromoted 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.

TypeScript
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 is seekToIndex(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.

TypeScript
// 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:

TypeScript
await player.ready();
// or
player.on('ready', () => {
/* all plugins resolved */
});

Plugin system replacement

v1 used a string-keyed instance registry:

TypeScript
// 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:

TypeScript
// v2
player.addPlugin(OctopusPlugin, {
/* options */
});
const plugin = player.getPlugin(OctopusPlugin); // typed return

Key differences:

  • addPlugin(Cls, opts) replaces both registerPlugin and usePlugin, the plugin activates immediately
  • getPlugin(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 form player.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: playerUIPluginDesktopUiPlugin

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.

TypeScript
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:

TypeScript
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.

TypeScript
// 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 fieldv2 field
accessTokenauth: { bearerToken: ... }
basePath(removed, use baseUrl)
displayLanguagelanguage
customStoragestorage
loglogLevel + logger
controlsTimeoutDesktop UI plugin option
doubleClickDelayTouch Zones plugin option

Removed config fields

v1 fieldReason
disableHlsBackend decision, use backendFactory
forceHlsBackend decision, use backendFactory
floatConsumer viewport concern
pip (config object)Replaced by pip() method + desktop UI plugin
messagePluginUse addPlugin(MessagePlugin)
disableTouchControlsUse addPlugin(TouchZonesPlugin) and disable
chapters (boolean)Always through IChapterSource adapter
stretching: '16:9' / '4:3'Use 'uniform' + player sizing

New config fields

v2 fieldDescription
authAuth pipeline config: bearer token, refresh, request signing
backendFactoryInject a custom IVideoBackend
defaultSubtitleLanguageLanguage code for initial subtitle selection
defaultAudioLanguageLanguage code for initial audio track selection
defaultQuality'auto' or a quality level index
preloadLeadSecondsSeconds ahead to preload
castCast config for CastSenderPlugin
platformFull 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 with queue(items) + item(item) navigation
  • hasSpeeds(), hasQualities(), hasAudioTracks(), hasSubtitles() calls in control-visibility logic need replacing with .length checks
  • seasons() call in the series view needs replacing with a groupBy over player.queue()
  • progress.timeprogress.timestamp in continue-watching logic
  • item.duration formatting: was string, now number in seconds
  • item.fileitem.url in 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