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 lacksartwork; - 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,loopandplaysinline. - 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: checkmatchMediaand listen for itschangeevent. - Reveal the video only once it can show a frame (
loadeddata, or better the firstrequestVideoFrameCallback) and fade it in over the static cover. Showing it earlier flashes an empty or black box. - On
error, or when the URL goesnull, fade back to the static cover and release the video withvideo.removeAttribute('src')and thenvideo.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 (
200becomes"200"), and so are booleans (truebecomes"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,callbackandmethod, and adds its ownapi_keyandformat=json. - A
user.*orlibrary.*method without auserparameter gets the signed-in user’s name. Passuseryourself 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
timeoutif 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
timeoutrather thanrateLimited, 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.getInfoanduser.getRecentTrackswithout atoparameter: 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
httpsURLs 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 withurlNotAllowed. - A download over 1 MB rejects with
tooLarge; one that is not a JPEG, PNG, GIF or WebP image (an SVG included) rejects withnetwork. The other codes matchcall():permissionDenied,rateLimited,networkandtimeout. 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/tofilter 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
genrerows can add up to more than the total. A play with no album is left out ofalbumrows only. sortdefaults tokey(chronological) for time groups and toplaysfor entities.limitapplies 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().sincethere is no data. Show earlier days as unknown, not as silent. recent()returns music plays newest first:limitdefaults to 50, at most 200. Pass the last play’sstartedAtasbeforefor the next page. Each play hasstartedAt,title,artist,album(ornull),seconds,duration(ornull),player(appleMusic|spotify|unknown),listenedandskipped.
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,toorbeforethat is not an ISO 8601 instant with a time zone (Zor an offset). A bare date such as'2026-08-01'is not an instant.- an invalid
Date(new Date('nonsense')) anywhere tothat is not later thanfrom- an unknown
groupByorsort - a
limitthat is not a whole number of at least 1, for anygroupByand forrecent() - a
limitpassed as a string: it must be a number (limit: 50, not'50'), unlikelastFm.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);
xandyare whole CSS pixels from the top-left of the viewport, the same space asclientX/clientY,getBoundingClientRect()andelementFromPoint().xgrows to the right even in adir="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.
pointerleavefires once, when the pointer leaves the window, and carries no data. Until the firstpointermove, assume the pointer is elsewhere. - Events are coalesced: at most about 30
pointermovea 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.