Skip to content

Migration Guide, nomercy-music-player v1 to v2

This guide covers every breaking change between @nomercy-entertainment/nomercy-music-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 V1MusicCompatPlugin right after constructing the player.

Code
npm install @nomercy-entertainment/nomercy-music-player@beta
TypeScript
import nmMPlayer from '@nomercy-entertainment/nomercy-music-player';
import { V1MusicCompatPlugin } from '@nomercy-entertainment/nomercy-music-player/plugins/v1-compat';

const player = nmMPlayer('music-player');
player.addPlugin(V1MusicCompatPlugin);

await player.setup({ /* your existing config */ });

// Your existing v1 code keeps working:
player.setQueue(tracks);
player.setCurrentSong(track);
player.on('song', (track) => console.log(track.name));

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-music-player. The import { nmMPlayer, NMMusicPlayer } resolves identically. If you are on npm ^1.x you will not automatically receive v2, 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. Features that were always-on (Equalizer, Spectrum, MediaSession) are now opt-in plugins.

Read the checklist below, then work through each section.

Quick migration checklist

  • Replace player.seek(time) with player.time(t, opts?)
  • Replace player.getDuration() with player.duration()
  • Replace player.getCurrentTime() with player.time()
  • Replace player.getBuffer() with player.buffered()
  • Replace player.getTimeData() with player.timeData() (payload shape also changed)
  • Replace player.setVolume(v) with player.volume(v)
  • Replace player.getVolume() with player.volume()
  • Replace player.getQueue() with player.queue()
  • Replace player.setQueue(items) with player.queue(items, opts?)
  • Replace player.addToQueue(item) with player.queueAppend(item, opts?)
  • Replace player.pushToQueue(items) with player.queueAppend(items, opts?)
  • Replace player.removeFromQueue(item) with player.queueRemove(id, opts?)
  • Replace player.addToQueueNext(item) with player.queuePrepend(item, opts?)
  • Replace player.getBackLog() with player.backlog()
  • Replace player.setBackLog(items) with player.backlog(items)
  • Replace player.addToBackLog(item) with player.backlogAppend(item)
  • Replace player.removeFromBackLog(item) with player.backlogRemove(id)
  • Replace player.playTrack(track, tracks?) with player.item(track, opts?) + player.queue(tracks)
  • Replace player.setCurrentSong(item) with player.item(item, opts?)
  • Replace player.currentSong (property) with player.item() (method)
  • Replace player.shuffle(value) with player.shuffleState(value) (boolean to ShuffleState enum)
  • Replace player.repeat(value) with player.repeatState(value)
  • Replace player.prepareCrossfade(item?) with player.crossfadeTo(track, opts?), see note below
  • Replace player.setAccessToken(token) with player.auth({ bearerToken: token })
  • Replace player.setBaseUrl(url) with player.baseUrl(url)
  • Replace player.isShuffling with player.shuffleState()
  • Replace player.isRepeating with player.repeatState()
  • Replace player.isMuted with player.volumeState()
  • Replace player.isPlaying with player.playState()
  • Replace player._crossfadeActive with player.isTransitioning()
  • Replace player.context with player.audioContext()
  • Replace item.path with item.url on every track object (see silent-break risk)
  • Move EQ calls from player.loadEqualizerSettings() / player.setPreGain(gain) / player.setFilter(band) to player.getPlugin(EqualizerPlugin).band(freq, gain) and .preGain(value)
  • Move MediaSession from constructor options to player.addPlugin(MediaSessionPlugin)
  • Move Spectrum from constructor options to player.addPlugin(SpectrumPlugin)
  • Move onCrossfadeStart / onCrossfadeComplete constructor callbacks to player.on('crossfadeStart', fn) / player.on('crossfadeComplete', fn)
  • Update every player.on(...) event handler, payload shapes changed for all events
  • Remove siteTitle from constructor options, the player must not touch document.title

Events renamed

v1 eventv2 event
songcurrent
fatalErrorfatal
loadstartsetupStart / beforeLoad

Events removed

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

v1 eventWhy removedv2 replacement
queueNextInternal implementation signal; leaked publiclyNo replacement, internal to AutoAdvancePlugin
startFadeOutInternal crossfade signalcrossfadeStart
endFadeOutInternal crossfade signalcrossfadeComplete
nextSongInternal implementation signalcurrent event
setCurrentAudioExposed internal HTMLAudioElementNo replacement, element is backend-internal
setPreGainEQ moved to pluginEqualizerPlugin.preGain(value) + player.on('plugin:equalizer:band:changed', ...)
setPannerEQ moved to pluginMixerPlugin.pan(value) + player.on('plugin:mixer:change', ...)
setFilterEQ moved to pluginEqualizerPlugin.band(freq, gain) + player.on('plugin:equalizer:band:changed', ...)
time-internalInternal timing detailNever public, do not rely on it
play-internalInternal playback detailNever public
pause-internalInternal playback detailNever public
loadedmetadataInternal media eventNot exposed in v2

Events, payload shapes changed

Every event that carried a raw HTMLAudioElement or a multi-field object now carries a typed wrapper. Update every player.on(...) call.

v1 eventv1 payloadv2 payload
playHTMLAudioElementActionOptions
pauseHTMLAudioElementActionOptions
endedHTMLAudioElementvoid
errorHTMLAudioElementPlayerErrorEvent
timeTimeState (5 fields){ time: number }
current (was song)S | null{ item: T | undefined; index: number }
shuffleboolean{ state: ShuffleState }
repeatRepeatState string{ state: RepeatState }
muteboolean{ muted: boolean }
volumenumber{ level: number }
seekedTimeState{ time: number }
crossfadeStartvoid{ from: T; to: T; duration: number }
crossfadeCompletevoid{ track: T }
durationnumber{ duration: number }
fatal (was fatalError){ error, recoverable, message }PlayerErrorEvent
canplayHTMLAudioElementbackend-internal only; not a consumer event (use firstFrame)
waitingHTMLAudioElementbackend-internal only; not a consumer event

Methods renamed

v1 methodv2 method
seek(time)time(t, opts?)
getDuration()duration()
getCurrentTime()time()
getBuffer()buffered()
getTimeData()timeData()
setVolume(v)volume(v)
getVolume()volume()
getQueue()queue()
setQueue(items)queue(items, opts?)
addToQueue(item)queueAppend(item, opts?)
pushToQueue(items)queueAppend(items, opts?)
removeFromQueue(item)queueRemove(id, opts?) (takes id, not full item)
addToQueueNext(item)queuePrepend(item, opts?)
getBackLog()backlog()
setBackLog(items)backlog(items)
addToBackLog(item)backlogAppend(item)
pushToBackLog(items)backlogAppend(items)
removeFromBackLog(item)backlogRemove(id) (takes id, not full item)
playTrack(track, tracks?)item(track, opts?) + queue(tracks)
setCurrentSong(item)item(item, opts?)
currentSong (property)item() (method)
shuffle(value)shuffleState(value) (boolean to ShuffleState enum)
repeat(value)repeatState(value)
prepareCrossfade(item?)crossfadeTo(track, opts?), semantics changed
setAccessToken(token)auth({ bearerToken: token })
setBaseUrl(url)baseUrl(url)
isShuffling (property)shuffleState() (method)
isRepeating (property)repeatState() (method)
isMuted (property)volumeState() (method, returns enum)
isPlaying (property)playState() (method, returns PlayState enum)
state (PlayerState enum property)playState() (method)
_crossfadeActive (property)isTransitioning() (method)
context (AudioContext property)audioContext() (method)
isPlatform(platform)isMobile() / isTv() (separate methods)
loadEqualizerSettings()player.getPlugin(EqualizerPlugin).bands()
saveEqualizerSettings()EqualizerPlugin persistence via persistKey option
setPreGain(gain)player.getPlugin(EqualizerPlugin).preGain(gain)
setPanner(pan)player.getPlugin(MixerPlugin)
setFilter(band)player.getPlugin(EqualizerPlugin).band(freq, gain)
setAutoPlayback(v)No config switch; autoplay is per-call via item(i, { autoplay: true })

item.pathitem.url

This is the highest-risk silent break in the migration. It is particularly sensitive for group listening and queue serialization.

v1 MusicPlaylistItem used path: string as the audio source URL. v2 uses url?: string.

If your server API returns track objects with a path field and you pass them to the player without mapping, the player will silently receive undefined for the source URL and fail to load the track.

TypeScript
// v1: server returns { path: '...', name: '...' }
// @ts-expect-error — setQueue does not exist in v2
player.setQueue(serverTracks); // worked because player read item.path

// v2: BREAKS. item.url is undefined, player cannot load the source.
player.queue(serverTracks);

// v2: correct. Map the field before passing to the player:
player.queue(serverTracks.map((track) => ({ ...track, url: track.path })));

For group listening: The queue is serialized and shared between participants in a listening session. If any participant’s queue contains items without url set, they will fail to load tracks. The server must emit url (or you must map pathurl before sharing the queue over the realtime channel).

For queue serialization (save/restore): If you persist queue state to storage using v1’s path-based items, those serialized queues will not load correctly in v2. Migrate stored queues on first v2 boot.

Full MusicPlaylistItem shape changes:

v1 fieldv2 fieldChange
path: string (required)url?: stringRenamed + now optional
name: string (required)name: string (required)Same
album_track: { name, ...any }[] (required)album?: stringRenamed + required → optional plain string
artist_track: { name, ...any }[] (required)artist?: stringRenamed + required → optional plain string
cover (typed as any)cover?: stringNow typed
(not present)lyricsUrl?: stringNew, source URL for LyricsPlugin
(not present)duration?: numberNew
id (via index signature)id (from BasePlaylistItem)Now typed and required

MusicPlaylistItem generic parameter

NMMusicPlayer<T extends MusicPlaylistItem> is generic in v2. Your track type is threaded through the player’s type system.

TypeScript
interface MyTrack extends MusicPlaylistItem {
userRating?: number;
isExplicit: boolean;
}

const player = nmMPlayer<MyTrack>('player-1').setup({ ... });

// player.item() returns MyTrack | undefined, no cast needed
const track = player.item();
track?.userRating;

shuffleState: boolean to enum

v1’s player.shuffle(value) accepted a boolean. v2’s player.shuffleState(value) accepts a ShuffleState enum.

TypeScript
import { ShuffleState } from '@nomercy-entertainment/nomercy-music-player';

// v1
// @ts-expect-error — shuffle does not exist in v2
player.shuffle(true);

// v2
player.shuffleState(ShuffleState.ON); // or ShuffleState.OFF

The shuffle event payload also changed from boolean to { state: ShuffleState }.

Crossfade API change

v1’s prepareCrossfade(item?) was a “pre-stage the secondary track for later” operation, designed to be called by server-side crossfade timing signals (e.g. via SignalR), staging the next track’s audio element before the fade timer fires. v2’s crossfadeTo(track, opts?) means “start the crossfade now.”

This is a semantic break, not just a rename. Server-orchestrated crossfade code must be restructured:

TypeScript
// v1: called by SignalR when server decides to start staging
// @ts-expect-error — prepareCrossfade does not exist in v2
player.prepareCrossfade(nextTrack);

// v2: there is no "stage then trigger later" one-liner.
// The crossfade itself becomes the server signal response:
signalR.on('crossfadeNow', (nextTrack) => {
player.crossfadeTo(nextTrack, {
curve: 'equal-power',
duration: 3,
});
});

If you need to pre-load the secondary track’s audio without starting the crossfade, this is an open feature gap (tracked as audit item Q-4). For now, coordinate with server to send the “start crossfade” signal earlier to account for network + decode time.

ready event timing

v1 fired ready after a hardcoded 1500ms setTimeout in the constructor. v2 fires ready after the full setup pipeline resolves, including all plugins’ async use() promises.

If you have any code that races against the 1500ms delay or depends on ready firing after an approximate time, it will behave differently in v2.

Use await player.ready() to wait for setup:

TypeScript
await player.ready();
// all plugins resolved; safe to interact with the player

Plugin system replacement

v1 had a string-keyed registry but most v1 music player users never called it explicitly, features like EQ, Spectrum, and MediaSession were wired into the player class itself.

In v2, every non-core feature is a plugin you must explicitly register. Nothing is always-on.

TypeScript
import nmMPlayer from '@nomercy-entertainment/nomercy-music-player';
import { AutoAdvancePlugin, EqualizerPlugin, SpectrumPlugin, MediaSessionPlugin, AudioGraphPlugin } from '@nomercy-entertainment/nomercy-music-player/plugins';

// v1: features built in, configured via constructor
// new NMMusicPlayer({ motionConfig, actions, onCrossfadeStart, onCrossfadeComplete })
// — constructor no longer accepts options; use addPlugin() instead

// v2: everything is an opt-in plugin
const player = nmMPlayer('player-1').setup({
crossfadeDefaults: {
duration: 3,
curve: 'equal-power',
},
});

player
.addPlugin(AudioGraphPlugin) // required by EQ + Spectrum
.addPlugin(EqualizerPlugin, { presets: myPresets })
.addPlugin(SpectrumPlugin, { /* audiomotion config here */ })
.addPlugin(MediaSessionPlugin)
.addPlugin(AutoAdvancePlugin);

player.on('crossfadeStart', fn);
player.on('crossfadeComplete', fn);

Plugins that moved from always-on to opt-in:

v1 (always on)v2 (opt-in)
Equalizer (WebAudio BiquadFilter)addPlugin(EqualizerPlugin) (requires AudioGraphPlugin)
Spectrum (audiomotion-analyzer)addPlugin(SpectrumPlugin) (requires AudioGraphPlugin)
MediaSessionaddPlugin(MediaSessionPlugin)
Auto-advance (play on ended)addPlugin(AutoAdvancePlugin)
motionConfig / motionColors constructor optionsSpectrumPlugin options at addPlugin
actions constructor optionRemoved; MediaSessionPlugin takes no options (metadata + handlers are derived from item fields)

Cross-plugin event listening uses the namespaced string form on player.on():

TypeScript
player.on('plugin:equalizer:band:changed', ({ band }) => {
// band.frequency, band.gain
});

Adapter injection

v2 introduces music-specific adapter interfaces (see packages/adapters/). Core adapters (storage, platform, auth) are wired through setup(). Music-specific adapters are wired through plugin options.

TypeScript
import nmMPlayer from '@nomercy-entertainment/nomercy-music-player';
import { LyricsPlugin } from '@nomercy-entertainment/nomercy-music-player/plugins';
import { LocalStorageBackend } from '@nomercy-entertainment/nomercy-player-core';

const player = nmMPlayer('player-1').setup({
// Kit adapters wired through setup():
storage: new LocalStorageBackend(),
auth: { bearerToken: () => myAuth.getToken() },

// Audio backend:
backend: 'webaudio', // 'audio-element' (default) or 'webaudio'
});

// Lyrics adapter wired through LyricsPlugin:
player.addPlugin(LyricsPlugin, {
getLyricsUrl: (track) => `https://api.example.com/lyrics/${track.id}.lrc`,
});

Music-specific adapter interfaces:

PortInterfaceDefault
audio-backendIAudioBackendAudioElementBackend
playlist-generatorIPlaylistGeneratorLinearPlaylistGenerator
similarity-engineISimilarityEnginenone (consumer-required)
scrobblerIScrobblerNoopScrobbler
lyric-sourceILyricSourceLrcFileSource (reads item.lyricsUrl)
now-playing-artINowPlayingArtMediaSessionArtProvider (standalone; not auto-wired)

See the core migration guide for the full core adapter catalog.

Constructor options that moved

v1 constructor optionv2 equivalent
siteTitle: stringRemoved, player must not touch document.title
expose: boolean (required)setup({ expose?: boolean }), optional in v2
debug: booleansetup({ logLevel: 'debug' })
motionConfigaddPlugin(SpectrumPlugin, { /* audiomotion opts */ })
motionColorsaddPlugin(SpectrumPlugin, { colors: [...] })
actions (MediaSession)Removed; MediaSessionPlugin takes no options
onCrossfadeStartplayer.on('crossfadeStart', fn)
onCrossfadeCompleteplayer.on('crossfadeComplete', fn)
fadeDurationsetup({ crossfadeDefaults: { duration: ... } })
prefetchLeewaysetup({ preloadLeadSeconds: ... })
disableAutoPlaybackRemoved; autoplay is opt-in per call via item(i, { autoplay: true })
baseUrlsetup({ baseUrl: ... })

Volume storage key migration

v1 stored volume under the key nmplayer-music-volume in localStorage. v2 uses the pluggable IStorage backend with keys namespaced under the player id. On first v2 boot, saved volume will not be restored from v1 storage.

If restoring saved volume matters for your users, read the v1 key before initializing the v2 player and pass it in:

TypeScript
// v1 stored volume as a 0-1 float; v2 uses the 0-100 scale.
const savedV1Volume = parseFloat(localStorage.getItem('nmplayer-music-volume') ?? '1');
const player = nmMPlayer('player-1').setup({
defaultVolume: Math.round(savedV1Volume * 100),
// ...
});

This is a one-time migration. v2 will persist using its own key going forward.

Subpath imports

v2 exports each adapter and plugin from a dedicated subpath. The root barrel import still works; subpaths are opt-in for tree-shaking.

TypeScript
// All plugins from the shared plugins subpath:
import { AutoAdvancePlugin, LyricsPlugin } from '@nomercy-entertainment/nomercy-music-player/plugins';

// Tree-shakeable per-plugin subpaths:
import { AutoAdvancePlugin } from '@nomercy-entertainment/nomercy-music-player/plugins/auto-advance';
import { LyricsPlugin } from '@nomercy-entertainment/nomercy-music-player/plugins/lyrics';
import { GroupListeningPlugin } from '@nomercy-entertainment/nomercy-music-player/plugins/group-listening';
import { WebAudioBackend } from '@nomercy-entertainment/nomercy-music-player/adapters/audio-backend';

Upgrading downstream NoMercy projects

nomercy-app-web

High-impact areas:

  • Every player.on(...) music event handler needs payload updates (songcurrent, { item, index } payload shape, etc.)
  • Equalizer, Spectrum, and MediaSession wiring: move from constructor options to addPlugin calls
  • player.setPreGain() / player.setFilter() calls → player.getPlugin(EqualizerPlugin).*
  • prepareCrossfade(item) SignalR handler → crossfadeTo(track, opts)
  • item.pathitem.url in the adapter layer that maps server responses to track objects
  • player.currentSong property access → player.item() method call
  • player.isShuffling / player.isRepeating property access → method calls with enum types
  • player.isMuted / player.isPlayingplayer.volumeState() / player.playState()
  • Group listening queue serialization: verify url field is present in serialized items

nomercy-cast-player

The cast receiver is slated for a full rewrite. Music player migration should happen as part of that rewrite rather than as an incremental patch.

Getting help