JavaScript API

Every widget has access to a global window.NepTunes object. It’s your single entry point for reading player state, reacting to changes, and controlling playback.

State accessors

Convenience getters for the most common values:

NepTunes.state         // the full state object (below)
NepTunes.track         // current track info, or null
NepTunes.isPlaying     // Boolean
NepTunes.isPaused      // Boolean
NepTunes.isStopped     // Boolean
NepTunes.volume        // Number, 0–100
NepTunes.isMuted       // Boolean
NepTunes.playerType    // "appleMusic", "spotify", or null with no player running
NepTunes.capabilities  // what the current player supports (see below)

The state object

NepTunes.state (and the value passed to statechange) looks like this:

{
  track: {
    title: "Song Title",
    artist: "Artist Name",
    album: "Album Name",
    albumArtist: "Album Artist",
    duration: 180.5,          // seconds
    isLoved: true,            // Apple Music only
    isAdvertisement: true,    // present only during a Spotify ad
    artworkData: "base64…",   // present if the artwork permission is granted
    motionArtworkURL: "neptunes-media://motion/…" // NepTunes 4.1+, see Motion artwork
  },
  playerState: 2,             // 0 = unknown, 1 = stopped, 2 = playing, 3 = paused
  playerType: "appleMusic",   // or "spotify"
  volume: 75,                 // 0–100
  isMuted: false,
  shuffleEnabled: false,
  repeatMode: 1,              // 1 = all, 2 = one, 3 = off
  rating: 80,                 // 0–100, Apple Music only
  isLoved: true,
  isDisliked: false,
  playerPosition: 45.2,       // seconds into the track
  firstWeekday: 1,            // region's first weekday, 1…7 ISO (1 = Monday), NepTunes 4.1+
  capabilities: {
    canLove: true,
    canDislike: true,
    canRate: true,
    canAddToLibrary: false,
    hasThreeStateRepeat: true
  }
}

track is null when nothing is playing, always check for it before reading track.title.

Spotify advertisements

During a Spotify ad, track.isAdvertisement is true. The key is omitted entirely at every other time, so !track.isAdvertisement is a safe check in older widgets.

An ad is still a track: audio is playing, and title/artist carry whatever Spotify reports, which is usually the advertiser’s own copy rather than anything song-like. Treat it as unidentified: show a neutral placeholder instead of the title, and disable anything that acts on the track. Skipping and seeking are refused by Spotify during an ad, and lastFm.loveTrack / lastFm.unloveTrack are rejected when the requested track and artist match the ad that is playing, loving a different track from the user’s history still works normally.

NepTunes.on('statechange', (state) => {
    const track = state?.track;
    const isAd = !!track?.isAdvertisement;

    document.getElementById('title').textContent =
        isAd ? 'Advertisement' : (track?.title || 'Not Playing');
    document.getElementById('artist').textContent =
        isAd ? '' : (track?.artist || '');

    prevBtn.disabled = isAd;
    nextBtn.disabled = isAd;
});

Widgets that bundle neptunes-kit.js can call NTKit.displayInfo(state), which returns { title, artist } with the ad-collapsing already applied.

Actions

Each action requires the matching permission. Calling an action without its permission is a no-op.

Playback, playbackControl

NepTunes.playPause();   // toggle play / pause
NepTunes.next();        // skip to next track
NepTunes.previous();    // skip to previous track

Volume, volumeControl

NepTunes.increaseVolume();
NepTunes.decreaseVolume();
NepTunes.toggleMute();
NepTunes.setVolume(50);     // 0–100

Love & ratings, love / ratingControl

NepTunes.toggleLove();
NepTunes.toggleDislike();
NepTunes.increaseRating();
NepTunes.decreaseRating();
NepTunes.removeRating();
NepTunes.setRating(80);     // 0–100, Apple Music

Shuffle & repeat, shuffleRepeatControl

NepTunes.toggleShuffle();
NepTunes.toggleRepeat();    // cycles off → all → one

Player, playerActivation

NepTunes.activatePlayer();  // bring the music player to the front, or launch the preferred
                            // player if one is set, otherwise the last-used one
NepTunes.switchPlayer();    // switch between Apple Music and Spotify

Events

// Fires whenever track or playback state changes
NepTunes.on('statechange', (state) => {
  /* state is the new state object */
});

// Fires when the user changes this widget's settings
NepTunes.on('settingschange', (settings) => {
  /* settings is the new settings object */
});

// Fires on a system light/dark switch: e = { dark: true | false }
NepTunes.on('themechange', (e) => {
  /* recompute anything JavaScript derived from the theme */
});

// Only with "pointerTracking": true in the manifest (NepTunes 4.1+)
NepTunes.on('pointermove', ({ x, y }) => {
  /* viewport CSS pixels, see Pointer tracking below */
});
NepTunes.on('pointerleave', () => {
  /* the pointer left the widget window */
});

// Stop listening
NepTunes.off('statechange', myCallback);

Settings

Read the current values of your declared settings, keyed by their id:

const settings = NepTunes.settings;
if (settings.showArtwork) {
  /* … */
}

Artwork

When the artwork permission is granted, get a ready-to-use data URL for the current album art:

const url = NepTunes.getArtworkDataURL();
if (url) {
  document.getElementById('artwork').src = url;
}

Motion artwork

A widget whose manifest has "supportsMotionArtwork": true and the artwork permission (see Motion artwork in the manifest) gets the album’s Apple Music motion artwork, a short silent loop, as track.motionArtworkURL. getMotionArtworkURL() returns the same value, or null:

const loop = NepTunes.getMotionArtworkURL(); // "neptunes-media://motion/…" or null

It’s a local URL for a <video src>, and nothing else. NepTunes downloads the loop itself and serves only that file to your widget, which still has no network, so a fetch() of the URL fails.

It’s null whenever there’s nothing to play, so keep the static cover underneath and treat the video as a bonus:

  • the manifest doesn’t declare supportsMotionArtwork, or lacks artwork;
  • the user turned the widget’s Animated cover switch off;
  • the album has no motion artwork in the Apple Music catalog (most don’t). It’s matched by artist and album whatever the player, so a Spotify track from an album that has one gets it too;
  • the loop hasn’t downloaded yet. It arrives a few seconds after the track changes, with another statechange, so a new album starts on its static cover;
  • Reduce Motion or Low Power Mode is on;
  • the user doesn’t have NepTunes Pro;
  • playback was stopped with Stop, or an ad is playing.

Don’t take “the URL is there” to mean “music is playing”. When the queue runs out, the player reports stopped with its last track, and that track’s URL can stay. Decide from playerState whether to play, pause or drop the video, as the example below does.

The next track on the same album carries the same URL, featured-artist tracks and a compilation’s tracks included: keep the video playing instead of reloading it. (A player that reports no album artist, like Spotify, can drop the URL for a moment on a featured-artist track and bring the same one back.)

Go easy on the battery. A looping video costs a few percent of CPU and GPU while it runs; a paused one costs nothing.

  • Keep it muted, loop and playsinline.
  • Play it only while music is playing (playerState === 2). Pause it on pause, which keeps the current frame, and drop it when playback stops.
  • Honour prefers-reduced-motion: reduce: check matchMedia and listen for its change event.
  • Reveal the video only once it can show a frame (loadeddata, or better the first requestVideoFrameCallback) and fade it in over the static cover. Showing it earlier flashes an empty or black box.
  • On error, or when the URL goes null, fade back to the static cover and release the video with video.removeAttribute('src') and then video.load().
  • A hidden widget window loads no media until it’s shown again.
const video = document.querySelector('video.motion'); // <video class="motion" muted loop playsinline>
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
let shown = null;
let failed = null; // don't retry a loop that errored

function updateMotion(state) {
  const url = reduced.matches ? null : NepTunes.getMotionArtworkURL();
  const active = state && (state.playerState === 2 || state.playerState === 3);
  const src = active && url !== failed ? url : null;
  if (src !== shown) {
    shown = src;
    video.classList.remove('visible');
    if (src) video.src = src;
    else { video.removeAttribute('src'); video.load(); }
  }
  if (src && state.playerState === 2) video.play().catch(() => {});
  else if (src) video.pause();
}

video.addEventListener('loadeddata', () => video.classList.add('visible'));
video.addEventListener('error', () => { failed = shown; updateMotion(NepTunes.state); });
NepTunes.on('statechange', updateMotion);
reduced.addEventListener('change', () => updateMotion(NepTunes.state));
updateMotion(NepTunes.state);

The Sleeve widget in the gallery is a complete example.

Last.fm

With the lastFm permission, NepTunes.lastFm reads Last.fm through the app. The app keeps the API key and the user’s session and never gives either to a widget, which still has no network of its own.

lastFm.call(method, params)

Sends any read-only Last.fm API method and resolves to Last.fm’s JSON body, exactly as Last.fm returned it (Last.fm’s shape, not a NepTunes one):

const body = await NepTunes.lastFm.call('user.getRecentTracks', {
  from: String(Math.floor(start.getTime() / 1000)),
  to: String(Math.floor(end.getTime() / 1000)),
  limit: '200',
  page: '1'
});
const scrobbles = body.recenttracks.track.filter((t) => !(t['@attr'] && t['@attr'].nowplaying));

Which methods. One rule decides, not a list: the method is <package>.<verb>, the package is user, library, album, artist, track, tag, chart or geo, and the verb starts with get. Every auth.* method and every write (track.love, track.scrobble, album.addTags and the rest) is refused with methodNotAllowed. Loving and unloving stay on loveTrack() / unloveTrack(), behind the love permission.

Parameters.

  • Values are strings. Numbers are converted (200 becomes "200"), and so are booleans (true becomes "1", false "0"). Any other value is dropped.
  • A key must look like a Last.fm parameter name: lowercase letters, digits and _, starting with a letter, at most 32 characters. Values are at most 1024 characters, and at most 20 parameters are kept. Anything else is dropped, not refused.
  • The app removes api_key, api_sig, sk, format, callback and method, and adds its own api_key and format=json.
  • A user.* or library.* method without a user parameter gets the signed-in user’s name. Pass user yourself to read another user’s public data; that works signed out too.
  • Requests are never signed and never carry a session key, so only public data can be read.

Errors. A failure rejects with an Error whose code says what happened:

code Meaning
permissionDenied the manifest does not declare lastFm. Rejected at once, without asking the app
methodNotAllowed the method fails the rule above, or is not a non-empty string
notSignedIn a user.* / library.* method without user, and nobody is signed in to Last.fm
rateLimited too many requests are already queued in the app, across all widgets
lastFm:<n> Last.fm’s own error n (for example lastFm:6, user not found); message is Last.fm’s
network the request failed, Last.fm answered with an HTTP error or a body that is not JSON, or NepTunes is not running
timeout no answer in time
tooLarge the response was over 2 MB

Timing and budget.

  • The app answers within about 9 seconds of receiving a request, with timeout if Last.fm has not answered by then. The promise itself gives up after 15 seconds, counted from the call: a request can first wait up to 5 seconds in the widget host, so under load the promise can time out before the app’s answer arrives.
  • Every widget on the Mac shares one limiter of 5 requests a second. Under load a widget usually hears timeout rather than rateLimited, because a request that waited in the widget host for more than 5 seconds is not sent. Treat the two alike: back off, then retry.
  • Identical calls are answered from a cache for 60 seconds, so polling faster than once a minute returns the same answer. The exceptions are user.getInfo and user.getRecentTracks without a to parameter: they change with every scrobble, so they are cached for 10 seconds. Page through big results a few requests at a time.

call() needs NepTunes 4.1, and widget updates reach people on older versions too, so feature-detect it:

if (typeof NepTunes.lastFm.call === 'function') {
  // the passthrough
} else {
  // the narrow methods below
}

lastFm.image(url)

A widget has no network, and that includes <img src="https://…">: a Last.fm image URL never loads in a widget. Ask the app for it instead:

const album = body.topalbums.album[0];
const art = (album.image || []).find((i) => i.size === 'extralarge');
if (art && art['#text']) img.src = await NepTunes.lastFm.image(art['#text']); // "data:image/…"
  • Only https URLs on Last.fm’s image hosts (lastfm.freetls.fastly.net, lastfm-img2.akamaized.net) are fetched. Anything else, including a redirect off those hosts, rejects with urlNotAllowed.
  • A download over 1 MB rejects with tooLarge; one that is not a JPEG, PNG, GIF or WebP image (an SVG included) rejects with network. The other codes match call(): permissionDenied, rateLimited, network and timeout. Images have their own limiter (10 a second), so artwork never spends the API budget.
  • The result is a data: URL, so it can be drawn to a <canvas> and sampled for colour.
  • Needs NepTunes 4.1; feature-detect it the same way as call().

The narrow methods

The original methods remain supported. They return simplified NepTunes shapes, reject when nobody is signed in to Last.fm, and time out after 15 seconds. New code should prefer call(), which reaches the whole read API.

NepTunes.lastFm.getUserInfo();
NepTunes.lastFm.getTopAlbums(period, limit);   // period: overall, 7day, 1month, 3month, 6month, 12month
NepTunes.lastFm.getTopArtists(period, limit);
NepTunes.lastFm.getTopTracks(period, limit);
NepTunes.lastFm.getRecentTracks(limit);
NepTunes.lastFm.getArtistInfo(artist);
NepTunes.lastFm.getTrackInfo(track, artist);
NepTunes.lastFm.loveTrack(track, artist);      // also needs the love permission
NepTunes.lastFm.unloveTrack(track, artist);    // also needs the love permission

Listening history

NepTunes keeps its own record of what the user listened to, independent of Last.fm scrobbling. With the listeningHistory permission a widget reads it through NepTunes.history. Queries run in the main app; a widget never touches the database.

This is the most private data a widget can get, so the user sees the permission at install. It needs NepTunes 4.1: older versions will not install or update to a bundle that declares it. Feature-detect anyway:

if (NepTunes.history && typeof NepTunes.history.query === 'function') {
  /* … */
}
const { since, plays } = await NepTunes.history.info();
// since: ISO 8601 instant of the first recorded play, or null; plays: total listened plays

const { rows } = await NepTunes.history.query({
  from: '2026-08-01T00:00:00Z',   // ISO 8601 instants or Date objects, half-open: from ≤ start < to
  to:   '2026-09-01T00:00:00Z',
  groupBy: 'day',                 // day | hour | weekday | artist | album | track | genre | none
  sort: 'key',                    // optional: plays | seconds | key
  limit: 50                       // optional, entity groups only; a number, not a string
});
// → [{ date: '2026-08-01', plays: 14, seconds: 3120 }, …]

const { plays: recent } = await NepTunes.history.recent({ limit: 20 });
const { plays: older } = await NepTunes.history.recent({
  limit: 20,
  before: recent[recent.length - 1].startedAt
});

Rows. Each groupBy gives its rows these keys:

groupBy Row keys
day date: "YYYY-MM-DD"
hour hour: 0…23
weekday weekday: 1…7, ISO (1 = Monday, 7 = Sunday)
artist artist
album album, artist (the album artist; null when unknown)
track title, artist, album (the album it was last played from in the range; null when none)
genre genre
none no key; exactly one row of totals for the range, zeros included

Every row also carries plays, the listened plays (half the track or four minutes, whichever comes first, the threshold Last.fm uses for scrobbles), and seconds, the time actually heard, counting every music play including ones skipped early.

What to rely on.

  • from/to filter on when a play started. Time buckets use the local wall-clock time where the play happened. Only music counts.
  • A play with several genres counts once under each, so genre rows can add up to more than the total. A play with no album is left out of album rows only.
  • sort defaults to key (chronological) for time groups and to plays for entities.
  • limit applies to entity groups: default 50, at most 1000. Time groups always cover the whole range, but a bucket with no plays may be missing: treat a missing row as zero.
  • Before info().since there is no data. Show earlier days as unknown, not as silent.
  • recent() returns music plays newest first: limit defaults to 50, at most 200. Pass the last play’s startedAt as before for the next page. Each play has startedAt, title, artist, album (or null), seconds, duration (or null), player (appleMusic | spotify | unknown), listened and skipped.

Errors reject with an Error whose code is:

code Meaning
permissionDenied the manifest does not declare listeningHistory. Rejected at once, without asking the app
unavailable the history database could not be opened or read, or NepTunes is not running
invalidQuery a missing or malformed argument (see below)
timeout the query took too long. The app gives up after about 8 seconds; the promise itself after 15

invalidQuery covers:

  • from, to or before that is not an ISO 8601 instant with a time zone (Z or an offset). A bare date such as '2026-08-01' is not an instant.
  • an invalid Date (new Date('nonsense')) anywhere
  • to that is not later than from
  • an unknown groupBy or sort
  • a limit that is not a whole number of at least 1, for any groupBy and for recent()
  • a limit passed as a string: it must be a number (limit: 50, not '50'), unlike lastFm.call() parameters
  • an argument that cannot be sent at all, such as a function

First day of the week

state.firstWeekday is the first day of the week in the user’s region, 1…7 in ISO numbering (1 = Monday, 7 = Sunday), following the Mac’s Language & Region settings. It arrives with the first statechange, so read it there, not at load. It is absent before NepTunes 4.1. Date.getDay() numbers Sunday 0; (date.getDay() + 6) % 7 + 1 converts it to ISO.

A widget that lays out weeks should follow the region by default and may offer an override, a select setting with the id firstWeekday and the options system, monday, sunday and saturday. Resolve it with this function. Copy it into your widget; NepTunes does not provide it:

function firstWeekday(settingValue, state) {
  var explicit = { monday: 1, sunday: 7, saturday: 6 };
  if (Object.prototype.hasOwnProperty.call(explicit, settingValue)) return explicit[settingValue];
  var fromHost = state && state.firstWeekday;
  if (Number.isInteger(fromHost) && fromHost >= 1 && fromHost <= 7) return fromHost;
  try {
    var locale = new Intl.Locale(state.locale);
    var week = typeof locale.getWeekInfo === 'function' ? locale.getWeekInfo() : locale.weekInfo;
    if (week && Number.isInteger(week.firstDay) && week.firstDay >= 1 && week.firstDay <= 7) return week.firstDay;
  } catch (e) {}
  return 1;
}

function weekStart() {
  return firstWeekday(NepTunes.settings && NepTunes.settings.firstWeekday, NepTunes.state);
}
NepTunes.on('statechange', render);
NepTunes.on('settingschange', render);

It returns the explicit choice if there is one, otherwise state.firstWeekday, otherwise the week data of state.locale where the browser engine has it (for NepTunes before 4.1), otherwise Monday. Call it when you render, on both statechange and settingschange.

Pointer tracking

Widget windows never become the key window, so WebKit receives no mouse-moved events: CSS :hover does not follow the pointer and mousemove fires only while a button is held. Every widget gets an nt-hover class on <html> while the pointer is over its visible content, which is enough to reveal controls. A widget that needs to know where the pointer is (a per-cell callout in a grid, a scrubber preview) opts in from the manifest:

"pointerTracking": true

Register the listeners synchronously while your script loads, not after an await or a timer: when the page finishes loading, the host reports a pointer already resting on the widget once, and a listener added later misses it.

NepTunes.on('pointermove', ({ x, y }) => {
  const cell = document.elementFromPoint(x, y)?.closest('.cell');
  cell ? showCallout(cell) : hideCallout();
});
NepTunes.on('pointerleave', hideCallout);
  • x and y are whole CSS pixels from the top-left of the viewport, the same space as clientX/clientY, getBoundingClientRect() and elementFromPoint(). x grows to the right even in a dir="rtl" document.
  • Events arrive while the pointer is anywhere over the widget’s window, including the transparent shadow gutter, so hit-test against your own elements. pointerleave fires once, when the pointer leaves the window, and carries no data. Until the first pointermove, assume the pointer is elsewhere.
  • Events are coalesced: at most about 30 pointermove a second, and only when the position changed. A pointer that stops still has its final position delivered.
  • Nothing arrives while a mouse button is held. Clicks and window dragging behave exactly as without tracking.
  • These are NepTunes events on NepTunes.on, not DOM events: document.addEventListener('pointermove', …) receives nothing from the host.
  • Opt in only if you use it: every event is a message into your widget’s web process.
  • NepTunes before 4.1 ignores the key and sends nothing, so the widget must still work without it: a grid that stays readable without its callout.

← manifest.json referenceSettings schema →

NepTunes on an older Mac

NepTunes 4 needs macOS 26 or later. If your Mac runs an earlier version, the App Store can still install the last NepTunes release that works on it.

  1. Open the App Store and click your name in the bottom-left corner.
  2. Find NepTunes in your apps and click the download button.
  3. When the App Store offers the last compatible version, click Download.

This only works if you’ve downloaded NepTunes before with the same Apple Account. Apple doesn’t offer older versions to new downloads. If that’s you, get in touch.