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

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.

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 the modal offers for your provider.

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.

api.p2p

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.

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.