Build a plugin

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.

Quickstart

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:

manifest.json
{
  "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:

index.js
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.

The best documentation is working code: every built-in plugin lives in src-tauri/plugins in the app repo — lyrics providers, artwork sources, Last.fm scrobbling, and more, all in the same two-file format.

Run it in the app

Point the app at your working folder — no packaging needed while you develop.

  1. In Viboplr, open Settings → General and enable Debug mode.
  2. Under Developer → Dev plugin folder, choose your plugin's folder. It loads immediately and overrides any installed copy with the same id.
  3. Edit 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.

What a plugin can add

Everything a plugin contributes is declared under contributes in the manifest, then wired up with a matching api handler in activate.

Manifest keyWhat it adds
informationTypesMetadata sections on artist, album, track, and tag detail pages — bios, lyrics, stats, similar artists, reviews.
imageProvidersArtist and album artwork sources, tried in user-configurable priority order.
streamResolversPlayback fallbacks — given a track's metadata, return a playable URL when no local source exists.
downloadProvidersDownload sources for the unified download modal: by URI scheme, by metadata, or interactive search.
contextMenuItemsRight-click actions on tracks, albums, artists, playlists, and multi-track selections — on every surface.
sidebarItemsFull custom views in the sidebar, rendered from declarative view data (lists, cards, charts, settings rows…).
homeShelvesHorizontal shelves on the Home page — album cards, artist cards, playlist cards, or track rows.
settingsPanelA tab of your own inside the app's Settings.
eventHooksReactions 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.

API reference

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.

api — top level

Host information and logging.

appVersionThe 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.

api.library

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.

api.playback

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.

api.contextMenu

Right-click actions on every track, album, artist, and playlist surface.

onAction(actionId, handler)Handle clicks on your menu items; the handler receives the target (ids when available, always title/artist metadata).
registerItem(item)Add a menu item at runtime ({ id, label, targets, submenuLabel?, order? }); returns an unsubscriber.
unregisterItem(itemId)Remove a runtime-registered item.

api.home

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.

api.nowPlayingInfo

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" }.

api.ui

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).

api.storage

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 / deleteCacheDirInspect and prune the flat file cache.
files.writeJson / readJson / writeText / readTextNested 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 / moveFile management inside the plugin's directory.

api.network

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.

api.collections

getLocalCollections()The user's local-folder collections as [{ id, name, path }] — e.g. as download destinations.

api.playlists

save(data) / list() / delete(id) / getTracks(id)Saved playlists — source-aware and image-aware.

api.informationTypes

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.

api.imageProviders

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.

api.downloads

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.

api.scheduler

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.

api.system

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.

api.env

get(key)Read an allow-listed build-time environment variable (e.g. an API key baked into a build), or null.

The sandbox

Plugins run inside the app with a deliberately small surface — everything flows through api.

  • No DOM, no direct network. There is no 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.
  • Scoped storage. Key-value data and files are namespaced to your plugin — you can't read another plugin's data or arbitrary paths.
  • Allow-listed subprocesses. api.system.exec only runs binaries the host registers (currently yt-dlp and ffmpeg).
  • Declared API usage. The optional apiUsage manifest field ([{ "api": "network.fetch", "reason": "…" }]) tells users what your plugin touches and why — shown in the Extensions view.
  • Version gates. minAppVersion blocks install and update on older apps. Bumping your plugin's version clears its cached information values, forcing a clean re-fetch.
  • Maturity flags. "stability": "experimental" badges the plugin and keeps it out of onboarding recommendations; debugOnly: true hides it outside debug mode.

Package & publish

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.

  1. Zip it. Create my-plugin.zip with manifest.json and index.js at the zip root (no wrapper folder).
  2. Describe it. Add an update.json next to the zip in the same release:
update.json
{
  "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"
}
  1. Release it. Publish a GitHub release with both assets. The permanent endpoint …/releases/latest/download/update.json is what the app checks (about every 24 hours) for updates.
  2. Submit it. Paste that 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.
Full submission details are in the gallery repo's guide. For a working release pipeline (package script + CI), see the YouTube plugin repo.

Ship your first plugin

Two files, an idea, and the gallery does the rest.