A Viboplr plugin is two files of plain JavaScript — no build step, no framework. If you can write a fetch call, you can put lyrics, artwork, streaming sources, or whole views inside the app.
A plugin is a folder with a manifest and a script. That's the whole toolchain.
my-plugin/ ├── manifest.json metadata + what the plugin contributes └── index.js the code, plain JavaScript
The manifest declares who you are and what the plugin adds to the app. This one contributes an information section to artist detail pages:
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"author": "Your Name",
"description": "Fun facts about the artist you're playing",
"minAppVersion": "0.9.5",
"contributes": {
"informationTypes": [
{
"id": "artist_facts",
"name": "Facts",
"entity": "artist",
"displayKind": "rich_text",
"ttl": 604800
}
]
}
}
The script receives the api object, registers handlers in activate, and returns its exports:
function activate(api) {
api.informationTypes.onFetch("artist_facts", function (entity) {
var url = "https://api.example.com/facts?artist="
+ encodeURIComponent(entity.name);
return api.network.fetch(url).then(function (resp) {
if (resp.status !== 200) {
return { status: "error", message: "HTTP " + resp.status };
}
return resp.json().then(function (data) {
if (!data.summary) return { status: "not_found" };
return { status: "ok", value: { summary: data.summary } };
});
});
});
}
function deactivate() {}
return { activate: activate, deactivate: deactivate };
Fetch handlers resolve to { status: "ok", value }, { status: "not_found" }, or { status: "error", message? } — the app takes care of caching (per-type ttl, in seconds), retries, and rendering. That's a complete, publishable plugin.
src-tauri/plugins
in the app repo — lyrics providers, artwork sources, Last.fm scrobbling, and more, all in the same two-file format.
Point the app at your working folder — no packaging needed while you develop.
id.index.js or manifest.json, then hit Reload to pick up the changes.Plugin console.log output lands in the webview devtools, and api.log(level, message) writes to the app's persistent log. Installed plugins live in the app data directory under plugins/, but for development the dev folder is all you need.
Everything a plugin contributes is declared under contributes in the manifest, then wired up with a matching api handler in activate.
| Manifest key | What it adds |
|---|---|
| informationTypes | Metadata sections on artist, album, track, and tag detail pages — bios, lyrics, stats, similar artists, reviews. |
| imageProviders | Artist and album artwork sources, tried in user-configurable priority order. |
| streamResolvers | Playback fallbacks — given a track's metadata, return a playable URL when no local source exists. |
| downloadProviders | Download sources for the unified download modal: by URI scheme, by metadata, or interactive search. |
| contextMenuItems | Right-click actions on tracks, albums, artists, playlists, and multi-track selections — on every surface. |
| sidebarItems | Full custom views in the sidebar, rendered from declarative view data (lists, cards, charts, settings rows…). |
| homeShelves | Horizontal shelves on the Home page — album cards, artist cards, playlist cards, or track rows. |
| settingsPanel | A tab of your own inside the app's Settings. |
| eventHooks | Reactions to app events: track:started, track:scrobbled, track:liked, track:added, track:removed, scan:complete. |
Shelves, context-menu items, and now-playing info items can also be registered at runtime (api.home.registerShelf, api.contextMenu.registerItem, api.nowPlayingInfo.registerItem) when the set depends on user state.
The api object passed to activate is the plugin's entire world — every capability below is available to any plugin, and nothing else is. The canonical TypeScript definitions live in src/types/plugin.ts.
Host information and logging.
| appVersion | The running app version (semver string) — for feature-gating beyond the manifest's minAppVersion. |
| log(level, message, section?) | Write to the app's persistent log ("error" / "warn" / "info"). Prefer this over console.log for diagnostics. |
Read the user's library, search it, and react to library events.
| getTrackCount() | Total track count across enabled collections. |
| getTracks(opts?) | Tracks, filtered by artistId / albumId / tagId with limit / offset. |
| ftsTracks(query, opts?) | Full-text search over tracks; ftsArtists / ftsAlbums / ftsTags do the same per entity. |
| getArtists / getAlbums / getTags(opts?) | Paginated entity listings (getAlbums accepts artistId). |
| getTrackById(id) … | Single-entity lookups: getArtistById, getAlbumById, getTagById. |
| getHistory / getMostPlayed / getMostPlayedArtists(opts?) | Play history and most-played charts; opts.days switches to a rolling window. |
| getHistoryPlayCount() / getHistoryPlaysPage(opts?) | Total play count and cheap keyset-paginated raw plays — use these to stream long histories. |
| recordHistoryPlaysBatch(plays) | Batch-import scrobbles; returns { imported, skipped }. |
| applyTags(trackId, tagNames) | Tag a single track (database only). |
| applyTagsBulk(assignments) | Tag many tracks in one call: Array<[trackId, tagNames]>. |
| bulkUpdateTracks(trackIds, fields) | Bulk-edit artist / album / year / tags, written through to file metadata. |
| findDuplicates(opts?) | Duplicate track groups by normalized title + artist, keeper-first. |
| onTrackAdded / onTrackRemoved / onScanComplete(handler) | Library events. |
Control playback, feed the queue, and resolve streams. Event handlers receive metadata-only tracks (title, artist, album, duration) — no database ids.
| getCurrentTrack() / isPlaying() / getPosition() | Current playback state (synchronous). |
| playTrack(track) | Play a single track. |
| playTracks(tracks, startIndex?, context?) | Play a list; context ({ name, coverUrl?, source? }) gives the queue its banner. |
| insertTrack(track, position) / insertTracks(tracks, position) | Insert into the current queue (-1 = end). |
| onTrackStarted / onTrackScrobbled / onTrackLiked(handler) | Playback events; the scrobble threshold is 50% or 4 minutes. |
| onStreamResolve(providerId, handler) | Register a stream fallback: (title, artist, album, duration) → { url, label } | null. |
| onResolveStreamByUri(scheme, handler) | Resolve a custom URL scheme (e.g. myservice://id) to a playable URL. |
Shelves on the Home page. Fetch handlers have a 5-second budget — serve from cached state, not live network calls.
| onFetchShelf(shelfId, handler) | Resolve a shelf's items: (limit) → { status: "ok", items } | { status: "empty" } | { status: "error" }. |
| registerShelf(descriptor) | Add a shelf at runtime ({ id, title, displayKind, limit?, icon? }). |
| unregisterShelf(shelfId) | Remove a runtime-registered shelf. |
| onItemClick(shelfId, handler) | Take over card clicks — e.g. navigate into your own view instead of playing. |
| onResolvePlay(shelfId, handler) | Resolve a playlist card's tracks lazily when the user presses play. |
Items for the cycling info line in the mini player (the line under the track title).
| registerItem({ id, label, priority? }) | Add an item; the user toggles it in the mini player's context menu. |
| unregisterItem(id) | Remove a registered item. |
| onFetch(id, handler) | Resolve the item's text for the current track: (track) → { status: "ok", text } | { status: "empty" } | { status: "error" }. |
Render sidebar views and talk to the host UI. Views are declarative — you send data (lists, cards, charts, inputs, settings rows), the app renders native, skin-aware components.
| setViewData(viewId, data, opts?) | Render or update a plugin view; opts.scrollKey gives it per-view scroll memory. |
| showNotification(message) | Show a toast. |
| navigateToView(viewId) | Navigate to one of your views. |
| requestAction(action, payload) | Request a host-level action (play with context, navigate to an entity, downloads…). |
| onAction(actionId, handler) | Handle button / toggle / input events fired from your views. |
| setBadge(viewId, badge) | Dot or count badge on your sidebar item (null to clear). |
Persistent state, scoped to your plugin. Key-value pairs live in the app database; files live in the plugin's own data directory.
| get(key) / set(key, value) / delete(key) | JSON key-value store. |
| cacheFile(subdir, filename, url) | Download a URL into the plugin cache; returns the local path. |
| getCachePath / listCacheDirs / deleteCacheDir | Inspect and prune the flat file cache. |
| files.writeJson / readJson / writeText / readText | Nested file I/O; paths are string arrays of segments. |
| files.download(path, url) | Fetch a URL through the host and write it to disk. |
| files.getPath / exists / list / remove / copy / move | File management inside the plugin's directory. |
All network access goes through the host — no CORS restrictions, no direct fetch.
| fetch(url, init?) | HTTP request proxied through Rust; returns { status, text(), json() }. |
| openUrl(url) | Open a URL in the system browser. |
| onDeepLink(handler) | Receive deep links (e.g. OAuth callbacks) delivered to the app. |
| openBrowseWindow(url, opts?) | Embedded browser window with eval / messaging — for services without an API. |
| getLocalCollections() | The user's local-folder collections as [{ id, name, path }] — e.g. as download destinations. |
| save(data) / list() / delete(id) / getTracks(id) | Saved playlists — source-aware and image-aware. |
Provide values for the information sections you declare in the manifest, and read the shared cache. Values are keyed by entity name, so metadata is shared across libraries. Display kinds include rich_text, lyrics, entity_list, stat_grid, tag_list, ranked_list, image_gallery, and more.
| onFetch(infoTypeId, handler) | Provide a value: (entity) → { status: "ok", value } | { status: "not_found" } | { status: "error" }. |
| searchValues(query, opts?) | Substring-search across all cached values (any type — lyrics, bios, reviews…), with optional type / field / entity filters. |
| getValuesForEntity(entity) | Every cached value for one entity. |
| getValue(typeId, entity) | One cached value, or null. |
| onFetch(entity, handler) | Provide artwork for "artist" or "album": (name, artistName?) → { status: "ok", url } (or base64 data); the host downloads, caches, and falls through the provider chain on miss. |
Plug into the unified downloader — queueing, progress, tagging, and cover embedding are handled by the host.
| enqueue(request) | Queue a download ({ title, artistName?, uri?, format?, provider?, … }); returns its id. |
| onResolveByUri(providerId, handler) | Resolve a download for a URI scheme you own. |
| onResolveByMetadata(providerId, handler) | Resolve by (title, artist, album, duration, format) — automatic fallback downloads. |
| onInteractiveSearch(providerId, handler) | Power the manual search inside the download modal. |
| onInteractiveResolve(providerId, handler) | Resolve the search result the user picked. |
| onGetQualities(providerId, handler) | Supply the quality / format options the modal offers for your provider. |
Periodic background tasks, persisted across restarts.
| register(taskId, intervalMs) / unregister(taskId) | Schedule / remove a recurring task. |
| onDue(taskId, handler) | Run when the task comes due. |
| complete(taskId) | Mark the run finished so the next interval starts. |
Subprocesses are allow-listed by the host — currently yt-dlp and ffmpeg. Declare what you use via binaryDependencies in the manifest; plugins cannot add new binaries.
| exec(program, args?, opts?) | Run an allow-listed binary; returns { exitCode, stdout, stderr }. |
| getDependency(name) | The host's cached status for a binary: { installed, version, origin, latest } — never hits the network yourself. A cold cache may trigger one local version probe, so the first call of a session can take seconds. |
| get(key) | Read an allow-listed build-time environment variable (e.g. an API key baked into a build), or null. |
Bridge to the host's built-in libp2p engine — peer discovery, library search, streaming, and transfers between Viboplr nodes.
| start(relayMultiaddr?) / stop() | Bring the node up or down. |
| getStatus() / getDiagnostics() | Node state, NAT status, peers, transfer counters. |
| getMultiaddrs() / reserveRelay(multiaddr) | This node's dialable addresses; reserve a relay slot for hole-punching. |
| searchPeer(peerId, multiaddr, query, limit?) | Query a peer's shared library. |
| streamFromPeer / downloadFromPeer(…) | Play a peer's track, or pull a copy into a local collection. |
| getSharedCollections() / setSharedCollections(ids) | Which local collections this node shares. |
Plugins run inside the app with a deliberately small surface — everything flows through api.
window, document, or fetch. You get console, timers, and the standard built-ins (JSON, Math, Promise, …); HTTP goes through api.network.fetch, files through api.storage.api.system.exec only runs binaries the host registers (currently yt-dlp and ffmpeg).apiUsage manifest field ([{ "api": "network.fetch", "reason": "…" }]) tells users what your plugin touches and why — shown in the Extensions view.minAppVersion blocks install and update on older apps. Bumping your plugin's version clears its cached information values, forcing a clean re-fetch."stability": "experimental" badges the plugin and keeps it out of onboarding recommendations; debugOnly: true hides it outside debug mode.Plugins live in their own GitHub repos. The gallery is just an index that points at your releases — you ship updates by publishing a release, and installed copies auto-update within a day.
my-plugin.zip with manifest.json and index.js at the zip root (no wrapper folder).update.json next to the zip in the same release:{
"version": "1.0.0",
"minAppVersion": "0.9.5",
"file": "https://github.com/you/my-plugin/releases/download/v1.0.0/my-plugin.zip",
"changelog": "First release"
}
…/releases/latest/download/update.json is what the app checks (about every 24 hours) for updates.update.json URL into the gallery submission form. A bot validates your release and opens a PR; a maintainer merges it, and your plugin appears in the gallery and in the app's Extensions view.Two files, an idea, and the gallery does the rest.