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.
npm install @nomercy-entertainment/nomercy-music-player@beta
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)withplayer.time(t, opts?) - Replace
player.getDuration()withplayer.duration() - Replace
player.getCurrentTime()withplayer.time() - Replace
player.getBuffer()withplayer.buffered() - Replace
player.getTimeData()withplayer.timeData()(payload shape also changed) - Replace
player.setVolume(v)withplayer.volume(v) - Replace
player.getVolume()withplayer.volume() - Replace
player.getQueue()withplayer.queue() - Replace
player.setQueue(items)withplayer.queue(items, opts?) - Replace
player.addToQueue(item)withplayer.queueAppend(item, opts?) - Replace
player.pushToQueue(items)withplayer.queueAppend(items, opts?) - Replace
player.removeFromQueue(item)withplayer.queueRemove(id, opts?) - Replace
player.addToQueueNext(item)withplayer.queuePrepend(item, opts?) - Replace
player.getBackLog()withplayer.backlog() - Replace
player.setBackLog(items)withplayer.backlog(items) - Replace
player.addToBackLog(item)withplayer.backlogAppend(item) - Replace
player.removeFromBackLog(item)withplayer.backlogRemove(id) - Replace
player.playTrack(track, tracks?)withplayer.item(track, opts?)+player.queue(tracks) - Replace
player.setCurrentSong(item)withplayer.item(item, opts?) - Replace
player.currentSong(property) withplayer.item()(method) - Replace
player.shuffle(value)withplayer.shuffleState(value)(boolean toShuffleStateenum) - Replace
player.repeat(value)withplayer.repeatState(value) - Replace
player.prepareCrossfade(item?)withplayer.crossfadeTo(track, opts?), see note below - Replace
player.setAccessToken(token)withplayer.auth({ bearerToken: token }) - Replace
player.setBaseUrl(url)withplayer.baseUrl(url) - Replace
player.isShufflingwithplayer.shuffleState() - Replace
player.isRepeatingwithplayer.repeatState() - Replace
player.isMutedwithplayer.volumeState() - Replace
player.isPlayingwithplayer.playState() - Replace
player._crossfadeActivewithplayer.isTransitioning() - Replace
player.contextwithplayer.audioContext() - Replace
item.pathwithitem.urlon every track object (see silent-break risk) - Move EQ calls from
player.loadEqualizerSettings()/player.setPreGain(gain)/player.setFilter(band)toplayer.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/onCrossfadeCompleteconstructor callbacks toplayer.on('crossfadeStart', fn)/player.on('crossfadeComplete', fn) - Update every
player.on(...)event handler, payload shapes changed for all events - Remove
siteTitlefrom constructor options, the player must not touchdocument.title
Events renamed
| v1 event | v2 event |
|---|---|
song | current |
fatalError | fatal |
loadstart | setupStart / beforeLoad |
Events removed
These events no longer exist in v2. No compatibility shim is provided.
| v1 event | Why removed | v2 replacement |
|---|---|---|
queueNext | Internal implementation signal; leaked publicly | No replacement, internal to AutoAdvancePlugin |
startFadeOut | Internal crossfade signal | crossfadeStart |
endFadeOut | Internal crossfade signal | crossfadeComplete |
nextSong | Internal implementation signal | current event |
setCurrentAudio | Exposed internal HTMLAudioElement | No replacement, element is backend-internal |
setPreGain | EQ moved to plugin | EqualizerPlugin.preGain(value) + player.on('plugin:equalizer:band:changed', ...) |
setPanner | EQ moved to plugin | MixerPlugin.pan(value) + player.on('plugin:mixer:change', ...) |
setFilter | EQ moved to plugin | EqualizerPlugin.band(freq, gain) + player.on('plugin:equalizer:band:changed', ...) |
time-internal | Internal timing detail | Never public, do not rely on it |
play-internal | Internal playback detail | Never public |
pause-internal | Internal playback detail | Never public |
loadedmetadata | Internal media event | Not 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 event | v1 payload | v2 payload |
|---|---|---|
play | HTMLAudioElement | ActionOptions |
pause | HTMLAudioElement | ActionOptions |
ended | HTMLAudioElement | void |
error | HTMLAudioElement | PlayerErrorEvent |
time | TimeState (5 fields) | { time: number } |
current (was song) | S | null | { item: T | undefined; index: number } |
shuffle | boolean | { state: ShuffleState } |
repeat | RepeatState string | { state: RepeatState } |
mute | boolean | { muted: boolean } |
volume | number | { level: number } |
seeked | TimeState | { time: number } |
crossfadeStart | void | { from: T; to: T; duration: number } |
crossfadeComplete | void | { track: T } |
duration | number | { duration: number } |
fatal (was fatalError) | { error, recoverable, message } | PlayerErrorEvent |
canplay | HTMLAudioElement | backend-internal only; not a consumer event (use firstFrame) |
waiting | HTMLAudioElement | backend-internal only; not a consumer event |
Methods renamed
| v1 method | v2 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.path → item.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.
// 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 path → url 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 field | v2 field | Change |
|---|---|---|
path: string (required) | url?: string | Renamed + now optional |
name: string (required) | name: string (required) | Same |
album_track: { name, ...any }[] (required) | album?: string | Renamed + required → optional plain string |
artist_track: { name, ...any }[] (required) | artist?: string | Renamed + required → optional plain string |
cover (typed as any) | cover?: string | Now typed |
| (not present) | lyricsUrl?: string | New, source URL for LyricsPlugin |
| (not present) | duration?: number | New |
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.
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.
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:
// 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:
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.
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) |
| MediaSession | addPlugin(MediaSessionPlugin) |
| Auto-advance (play on ended) | addPlugin(AutoAdvancePlugin) |
motionConfig / motionColors constructor options | SpectrumPlugin options at addPlugin |
actions constructor option | Removed; MediaSessionPlugin takes no options (metadata + handlers are derived from item fields) |
Cross-plugin event listening uses the namespaced string form on player.on():
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.
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:
| Port | Interface | Default |
|---|---|---|
| audio-backend | IAudioBackend | AudioElementBackend |
| playlist-generator | IPlaylistGenerator | LinearPlaylistGenerator |
| similarity-engine | ISimilarityEngine | none (consumer-required) |
| scrobbler | IScrobbler | NoopScrobbler |
| lyric-source | ILyricSource | LrcFileSource (reads item.lyricsUrl) |
| now-playing-art | INowPlayingArt | MediaSessionArtProvider (standalone; not auto-wired) |
See the core migration guide for the full core adapter catalog.
Constructor options that moved
| v1 constructor option | v2 equivalent |
|---|---|
siteTitle: string | Removed, player must not touch document.title |
expose: boolean (required) | setup({ expose?: boolean }), optional in v2 |
debug: boolean | setup({ logLevel: 'debug' }) |
motionConfig | addPlugin(SpectrumPlugin, { /* audiomotion opts */ }) |
motionColors | addPlugin(SpectrumPlugin, { colors: [...] }) |
actions (MediaSession) | Removed; MediaSessionPlugin takes no options |
onCrossfadeStart | player.on('crossfadeStart', fn) |
onCrossfadeComplete | player.on('crossfadeComplete', fn) |
fadeDuration | setup({ crossfadeDefaults: { duration: ... } }) |
prefetchLeeway | setup({ preloadLeadSeconds: ... }) |
disableAutoPlayback | Removed; autoplay is opt-in per call via item(i, { autoplay: true }) |
baseUrl | setup({ 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:
// 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.
// 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 (song→current,{ item, index }payload shape, etc.) - Equalizer, Spectrum, and MediaSession wiring: move from constructor options to
addPlugincalls player.setPreGain()/player.setFilter()calls →player.getPlugin(EqualizerPlugin).*prepareCrossfade(item)SignalR handler →crossfadeTo(track, opts)item.path→item.urlin the adapter layer that maps server responses to track objectsplayer.currentSongproperty access →player.item()method callplayer.isShuffling/player.isRepeatingproperty access → method calls with enum typesplayer.isMuted/player.isPlaying→player.volumeState()/player.playState()- Group listening queue serialization: verify
urlfield 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
- Issue tracker: github.com/NoMercy-Entertainment/nomercy-music-player/issues
- Discord: NoMercy Entertainment server,
#player-devchannel - Testbed (live integration reference):
tools/player-testbed/in the monorepo - Core adapter catalog: Core, Adapters