Write a plugin

Two kinds: compiled-in Rust plugins with full access to the UI, and mpv user scripts (Lua or JavaScript) that need no rebuild.

Architecture

Plugins live in src/plugins/. Each one is a type implementing the Plugin trait and is listed in plugins::builtin(). The Registry owns them, keeps their enabled flag and settings in ~/.config/vivi/config.toml under [plugins.<id>], and calls the enabled ones from the UI thread:

mpv events ─▶ main.rs ─▶ plugins::file_loaded() ─▶ Plugin::file_loaded
timer (100 ms) ────────▶ Plugin::tick
Settings → Extensions ─▶ plugins::setting_changed() ─▶ enable / disable / setting_changed / command
Slint callback plugin-command(id, name, args) ─▶ Plugin::command

The Settings → Extensions page is generated from the manifests: one card with an ON/OFF toggle per plugin, followed by its settings while it is enabled. Nothing else needs to be registered.

Manifest format

FieldTypeMeaning
idstringStable id, lowercase with dashes (skip-intro). Used as the config key.
namestringDisplay name.
descriptionstringOne sentence, shown on the card.
versionstringSemver; built-ins use the Vivi version.
authorstringName or handle.
iconstringIcon key: visualizer, skip, lyrics, subtitles, script; anything else shows the generic plugin icon (see PluginIcons in ui/settings.slint).
settingslistSetting specs, below.
default_enabledboolState before the user touches the toggle.

Setting specs have a key, title, info (tooltip), a kind and a default. Values are always strings ("true"/"false" for toggles).

KindConstructorSettings row
toggleSettingSpec::toggle(key, title, info, default)ON/OFF toggle
choiceSettingSpec::choice(key, title, info, &[options])Dropdown; the first option is the default
textSettingSpec::text(key, title, info, placeholder, default)Text field
secretSettingSpec::secret(key, title, info)Hidden text field (API keys)
actionSettingSpec::action(key, title, info, label)Button; runs Plugin::command(cx, key, &[])

In config they look like this:

[plugins.subtitle-search]
enabled = true

[plugins.subtitle-search.settings]
api-key = "…"
languages = "en,uk"

A Rust plugin step by step

1. Create src/plugins/hello.rs:

use super::{Ctx, Manifest, Plugin, SettingSpec};

#[derive(Default)]
pub struct Hello;

impl Plugin for Hello {
    fn manifest(&self) -> Manifest {
        Manifest::builtin("hello", "Hello", "Greets every file you open.", "plugins")
            .setting(SettingSpec::text("greeting", "Greeting", "", "Hello", "Hello"))
    }
    fn file_loaded(&mut self, cx: &Ctx) {
        let title = cx.player.get("media-title").unwrap_or_default();
        cx.toast(&format!("{} {title}", cx.get("greeting")));
    }
}

2. Add pub mod hello; to src/plugins/mod.rs and Box::new(hello::Hello) to builtin().

3. Add an entry to plugins/index.json (a unit test checks that every built-in is listed with the same name, icon and setting keys), then run cargo test.

Hooks and context

HookWhen
enable(cx)At startup if enabled, and when switched on (followed by file_loaded).
disable(cx)When switched off (and at startup if disabled). Clear your UI here.
file_loaded(cx)mpv loaded a new file (tracks, chapters and metadata are available).
tick(cx)Every 100 ms while enabled. Keep it cheap; poll channels from worker threads here.
setting_changed(cx, key)The user changed one of your settings (already saved).
command(cx, name, args)An action button, or a UI callback sent with plugin-command.

Ctx gives you cx.app (the Slint AppWindow), cx.player (mpv: get, set, cmd, seek_abs, chapters(), mpv.get_property), cx.get(key) / cx.flag(key) / cx.set(key, value) for your settings, cx.toast(msg) and cx.local_path().

Hooks run on the UI thread. Do network and disk work on a std::thread and send the result back through an mpsc channel that tick polls (see lyrics.rs and subtitles.rs).

Adding UI

Put components in ui/plugins.slint, add properties to AppWindow in ui/app.slint, and set them from your hooks. To call back into the plugin, use the shared callback:

clicked => { root.plugin-command("hello", "wave", ""); }

which arrives as command(cx, "wave", &[""]). Join several arguments with U+001F.

mpv script plugins

Any mpv Lua (.lua) or JavaScript (.js) script, or a directory with main.lua / main.js, placed in ~/.config/vivi/scripts/ becomes an external plugin with its own card and toggle. Enabled scripts are loaded with mpv's load-script command at startup (unless “Load add-ons on startup” is off) or as soon as you switch them on. mpv cannot unload a script, so switching one off takes effect after a restart. Use Rescan to pick up new files without restarting.

Optional header comments fill in the card:

-- @name Now Playing OSD
-- @description Shows the title on mpv's OSD when a file starts
-- @version 1.0
-- @author Vivi

mp.register_event("file-loaded", function()
    mp.osd_message(mp.get_property("media-title", ""), 3)
end)

JavaScript scripts use // @name …. Scripts run inside Vivi's mpv, so they see the same properties and can bind commands, but mpv's own OSD is drawn under Vivi's UI and Vivi disables mpv's default key bindings; keys Vivi does not use itself (and all Alt combinations) are forwarded to mpv, so bind them with mp.add_forced_key_binding. Your libmpv must be built with Lua / JavaScript support (most distributions are).

Package & publish a downloadable plugin

Downloadable plugins are mpv Lua (or JavaScript) scripts with a manifest.toml, installed from Settings → Extensions → Browse. Why this format and how install, update and rollback work: Plugin distribution.

  1. Create plugins/<id>/ with manifest.toml, the entry script <id>.lua and optionally a README and more files.
  2. Write the manifest:
    id = "night-mode"
    name = "Night Mode"
    version = "1.0.0"          # semver; bump it for every change
    description = "..."
    author = "You"
    kind = "mpv-lua"           # or mpv-js
    entry = "night-mode.lua"   # file name = mpv script name = id
    min_vivi = "0.8.0"
    icon = "night"             # Settings icon key
    lucide = "moon"            # docs icon (lucide.dev)
    permissions = []           # network, filesystem, subprocess
    
    [[settings]]
    key = "strength"
    title = "Strength"
    type = "choice"            # toggle | choice | text | secret | action
    options = ["Medium", "Light", "Strong"]
    default = "Medium"
  3. Read settings in the script. Vivi passes them as script-opts named <id>-<key> and updates them live; toggles arrive as yes/no. Action settings arrive as script messages:
    local o = { strength = "Medium" }
    require("mp.options").read_options(o, "night-mode", function(changed) apply() end)
    mp.register_script_message("clear", clear)   -- an action setting with key "clear"
    mp.add_forced_key_binding("Alt+n", "toggle", toggle)
    Vivi disables mpv's default bindings and forwards keys it does not use (and every Alt combination) to mpv, so bind keys with mp.add_forced_key_binding; plain add_key_binding is ignored.
  4. Declare honest permissions: network for HTTP (e.g. via curl), filesystem for files outside the package folder, subprocess for running programs. Users see them before installing.
  5. Run scripts/package-plugins.py. It validates the manifest, builds a deterministic zip in dist/plugins/ and writes the entry (URL, sha256, size) into plugins/index.json. Test locally by copying the folder to ~/.config/vivi/scripts/.
  6. Commit and push (or open a pull request). The Plugins workflow checks Lua syntax and the index, then publishes a release plugin-<id>-v<version> with the zip and verifies the uploaded asset's sha256. The Docs site workflow publishes the new catalog; Vivi picks it up on its next daily check or on Check now.

A released version is immutable: change anything, bump version.

Listing in the catalog

The catalog is generated from plugins/index.json by scripts/build-site.py. Entries use the manifest fields above plus kind (builtin or script) and, for scripts, source (a path in the repository).

python3 scripts/build-site.py   # builds _site/
python3 -m http.server -d _site