Apollo Client plugins are trusted renderer modules. They are not sandboxed and can reach most of the client runtime through api.apollo.
Plugins can now:
- add detail tabs
- register lyrics providers
- read and mutate live app state
- drive playback, search, playlists, auth, and layout flows
- call Apollo API endpoints through the client request helpers
- subscribe to app lifecycle and playback events
- access the DOM, browser APIs, and the desktop bridge already exposed in preload
Plugins are loaded at runtime from disk-backed .js modules. Apollo seeds first-party plugins into the user-data plugins/ directory on first run, then loads plugins from the configured runtime directories.
- Plugins are plain ES modules loaded by the renderer with dynamic
import(...). - The host lives in
src/plugin-host.js. - The renderer builds the broad runtime surface in
src/renderer.js. - The preload bridge discovers runtime plugin files and reports them to the renderer.
Each plugin exports a default object:
const plugin = {
id: "example",
name: "Example Plugin",
async setup(api) {
// plugin bootstrap
}
};
export default plugin;Requirements:
idis required and uniquesetup(api)is requirednameis optional
Apollo also expects anything a plugin registers into shared runtime space to stay unique. Reusing another plugin's detail-tab id or lyrics-provider id is now treated as a load error.
setup(api) may return a cleanup function. The host runs it when the renderer unloads.
The setup API includes:
registerDetailTab(tab)registerLyricsProvider(provider)on(eventName, handler)emit(eventName, payload)onDispose(cleanup)escapeHtml(value)formatDuration(value)providerLabel(providerId)apollo
api.apollo is the main plugin runtime.
The runtime exposes:
apollo.stateapollo.playbackStateapollo.likedTracksapollo.cachesapollo.windowapollo.documentapollo.localStorageapollo.sessionStorageapollo.desktopapollo.domapollo.helpersapollo.snapshotsapollo.queriesapollo.netapollo.uiapollo.searchapollo.libraryapollo.playlistsapollo.playbackapollo.authapollo.events
Notes:
apollo.stateis the live renderer state object- direct state mutation requires explicit persistence and rendering
apollo.ui.commit(...)handles persistence and render triggersapollo.windowandapollo.documentare exposed because plugins already run in the renderer
Commonly used sections:
getVisibleTracks()getSelectedTrack()getTrackByKey(trackKey)getPlaybackTrack()getPlaybackTrackKey()getPlaylistItems()getPlaylists()getActivePlaylist()getEditablePlaylist()isTrackLiked(trackKey)isTrackInPlaylist(playlistId, track)getEnabledProviders()getCachedDuration(track)canSaveTrackToApollo(track)getPlugins()
getApiBase()requestJson(path, options)fetch(...)withAccessToken(url)getAuthorizationHeader()
render()renderStatus()renderPlayback()renderDetailPanel()setStatusMessage(message)setActiveDetailTab(tabId, options)togglePanel(panelId)resetLayout()openPlaylistModal(options)closePlaylistModal()openSettingsModal()closeSettingsModal()commit(options)
commit(options) supports:
likes: trueauth: trueplayback: truesettings: truelayout: truerenderApp: truerenderStatusOnly: true
getQuery()setQuery(query, options)runSearch()fetchSearchResults(query)
refreshLibrary()fetchAllTracks(query)queueDurationProbe(track)toggleLike(track)downloadTrackToDevice(track)downloadTrackToServer(track)
createPlaylist(name, description, initialTrackId)updatePlaylist(playlistId, name, description)deletePlaylist(playlistId)uploadPlaylistArtwork(playlistId, file)deletePlaylistArtwork(playlistId)addTrackToPlaylist(playlistId, track)removeTrackFromPlaylist(playlistId, track)
audioPlayerselectTrack(trackKey, options)playSelectedTrack()playTrack(trackOrKey, options)playAdjacent(offset, wrap)resolvePlaybackUrl(track)waitForPlaybackReady()getSnapshot()
getSession()refreshAuthStatus()signInWithSecret(secret)signOut()clearAuthSession()persistAuthSession()
Plugins can subscribe through:
api.on("event:name", handler)api.apollo.events.on("event:name", handler)
Current host events include:
plugins:loadedapp:renderapp:readyselection:changeddetail:tab-changelibrary:refresh:startlibrary:refresh:successlibrary:refresh:errorlibrary:like-changedsearch:startsearch:successsearch:errorsearch:clearedplayback:track-changedplayback:stateplayback:metadataplayback:errorauth:changed
Plugins may also emit custom events through emit(...).
registerDetailTab adds a tab to the right-hand detail panel.
api.registerDetailTab({
id: "queue-tools",
label: "Queue Tools",
order: 30,
mount({ container, context, apollo }) {
const track = context.getPlaybackTrack() || context.getSelectedTrack();
container.innerHTML = `
<div class="detail-empty-state">
<h3>Queue Tools</h3>
<p>${api.escapeHtml(track?.title || "Nothing selected.")}</p>
<button type="button" data-action="like">Toggle like</button>
</div>
`;
container.querySelector("[data-action='like']")?.addEventListener("click", () => {
const activeTrack = apollo.queries.getPlaybackTrack() || apollo.queries.getSelectedTrack();
if (!activeTrack) {
return;
}
apollo.library.toggleLike(activeTrack);
});
}
});Detail tab fields:
id: required unique stringlabel: required tab labelorder: optional sort ordermount(...): required render function
mount(...) receives:
containercontextapolloapipluginservices
services currently provides:
resolveLyrics(track)emit(eventName, payload)on(eventName, handler)
The tab mount may return a cleanup function.
The detail context currently includes:
audioPlayergetPlaybackTrack()getPlaybackTrackKey()getSelectedTrack()isTrackLiked(trackKey)providerLabel(providerId)apollo
registerLyricsProvider participates in lyrics resolution.
api.registerLyricsProvider({
id: "local-cache",
name: "Local Cache",
order: 5,
canResolve(track) {
return Boolean(track?.title && track?.artist);
},
async resolve(track) {
return {
source: "Local Cache",
synced: false,
plainText: "Example lyrics",
lines: [],
meta: {}
};
}
});Lyrics provider fields:
id: required unique stringname: required display nameorder: optional priority, lower runs firstcanResolve(track): optional pre-filterresolve(track): required async resolver
Resolver behavior:
- Return
nullto defer to the next provider. - Throwing does not stop later providers.
- If
linescontains entries, the result is treated as synced lyrics.
const automationPlugin = {
id: "automation-tools",
name: "Automation Tools",
async setup(api) {
api.on("app:ready", () => {
api.apollo.ui.setStatusMessage("Automation plugin loaded.");
});
api.registerDetailTab({
id: "automation-tools",
label: "Automation",
order: 50,
mount({ container, apollo }) {
container.innerHTML = `
<div class="detail-empty-state">
<h3>Automation</h3>
<button type="button" data-action="refresh">Refresh library</button>
</div>
`;
const button = container.querySelector("[data-action='refresh']");
const onClick = () => {
void apollo.library.refreshLibrary();
};
button?.addEventListener("click", onClick);
return () => {
button?.removeEventListener("click", onClick);
};
}
});
}
};
export default automationPlugin;Drop a trusted .js ES module into one of the runtime plugin directories:
APOLLO_PLUGIN_DIRplugins/next to the installed executableplugins/in the current working directoryplugins/in the Electron user-data directory
Apollo watches those directories and reloads plugins when files change.
- Plugins are trusted code.
- Plugins can break rendering, playback, auth, or state persistence if they mutate the runtime carelessly.
- Untrusted plugins should not be used in clients with access to private Apollo servers or valid auth tokens.
- Prefer
apollo.snapshotsfor read-heavy logic andapollo.ui.commit(...)after direct state mutation. - Clean up listeners, observers, and timers from
mountandsetup.