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. |
| getTrackLikeStates(tracks) | Read persisted like states for { title, artistName? }[]; parallel number[] (1 liked, -1 disliked, 0 neither). |
| setTrackLikesBatch(tracks) | Persist a batch of track likes/dislikes (e.g. imported loved tracks); newer-wins merge, idempotent. Returns rows applied. |
| 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). |
| playWithBackfill({ head, context?, resolveTail, tailErrorMessage? }) | Play the opening track(s) you already know now, and append the rest when your slow work finishes — no loading modal. head can be metadata-only (the stream chain resolves it); resolveTail() returns the full list and the host strips a leading duplicate. Prefer this over playTracks + insertTracks: the host drops the tail if the user changed the queue while you were resolving, and the queue panel shows its own "filling in" row for the whole wait. Resolves with the number of tracks actually appended (0 = nothing landed) — announce success from that, not from your resolved list. |
| 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, opts?) → { url, label, video? } | null. opts.preferVideo is an advisory hint (the user enabled “Prefer video”): return a video stream and set video: true to have the host play it in the theater, or ignore it to keep returning audio. The hint never overrides source priority. |
| onResolveStreamByUri(scheme, handler) | Resolve a custom URL scheme (e.g. myservice://id): (id, quality?, opts?) → string | { candidates: StreamCandidate[] } | null. Return a bare URL for one self-contained stream, or a candidate list when the source splits into separate video/audio streams (e.g. YouTube ≥720p). opts.externalAudio is set when the native mpv engine will render this as video and can merge a separate audio track — return the split candidates then, else a muxed URL. StreamCandidate: { url, kind: "muxed"|"video"|"audio", height?, container?, vcodec?, acodec?, tbr? }; the host picks per its active engine. |
| onResolveStoryboard(scheme, handler) | Supply seek-preview thumbnails for a custom URL scheme: (id) → Storyboard | null. The host shows one tile in the seek bar's hover bubble. Prefer the source's own published storyboard over extracting frames — YouTube serves sprite sheets (~58 KB for 200 thumbnails), so nothing is decoded and the video is never re-streamed. Cache the sheet bytes via api.storage.files.download, not the URLs: signed media URLs expire within hours while the images are permanent. 10 s host timeout; null simply means no preview. Storyboard: { sheets: string[], cols, rows, count, tileW, tileH, startSecs, intervalSecs } — tiles row-major per sheet, sheets consecutive in time, count = tiles that actually carry a frame. |
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. Ship the track the list starts with plus partial: true on the item and it plays instantly while your resolver fills in the rest — no loading modal. |
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 ({ value, label, video? }[]) the modal offers for your provider. Mark a video option with video: true and the modal defaults to it when the track being downloaded is a video. |
Your resolve handlers return { url, headers?, metadata?, ext? }. Always return a concrete ext when you know the container ("mp3", "flac", "m4a", …) — naming the file is the provider's job. ext: "auto" asks the host to detect the format from the downloaded bytes, but that only happens on the background queue (batch) path; the single-track download window resolves the extension before the bytes arrive and never sniffs, so there "auto" falls back to a default (and to a neutral .bin if you also declared no onGetQualities). Don't rely on "auto" for single-track downloads.
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. |
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.