spotatui can run user-written Lua plugins. Plugins react to playback events and can drive
playback through a small, curated API. Scripting is compiled in behind the scripting
feature, which is enabled in the default build.
Plugins are loaded from your config directory (~/.config/spotatui/) at startup, in this order:
init.lua, if present.- Single-file plugins: every
plugins/*.luafile, sorted by filename. - Directory plugins: every
plugins/<name>/folder, sorted by name. The entry point ismain.lua, falling back toinit.lua. A directory with neither is skipped, and directories whose name starts with.are ignored.
A directory plugin's own folder is added to Lua's package.path, so it can split itself across
files and load them with require("module") (resolving to plugins/<name>/module.lua).
package.path and the module cache are shared across all plugins: if two plugins both
require("util"), the first-loaded plugin's util.lua is cached under that name and silently
handed to the later plugin as well. Give helper modules distinctive (e.g. plugin-prefixed) names.
Directory plugins are how the spotatui plugin installer (below) lays out git-cloned plugins.
Missing files or a missing plugins/ directory are fine. If a file fails to load, the error
is logged and shown as a status message, and the remaining plugins still load.
Plugins are not sandboxed. A plugin runs with the same privileges as spotatui itself: it has the
full Lua standard library (including filesystem access via io/os) and can make arbitrary
network requests through spotatui.http_get/http_post. spotatui plugin add clones a git
repository and runs whatever its main.lua contains the next time you start spotatui.
Treat installing a plugin like running any other program from the internet: only install plugins whose source you have read or whose author you trust, and prefer repositories you control. There is no permission prompt and no isolation between a plugin and your account.
A plugin published as a git repository can be installed with the spotatui plugin command. This
requires git on your PATH and does not need Spotify authentication.
spotatui plugin add owner/repo # GitHub shorthand
spotatui plugin add https://gitlab.com/owner/repo.git
spotatui plugin add owner/repo --force # reinstall over an existing copy
spotatui plugin list # show installed plugins
spotatui plugin update # update every plugin to its latest commit
spotatui plugin update <name> # update just one
spotatui plugin remove <name> # uninstall
spotatui plugin new <name> # scaffold a new plugin to start fromadd clones the repository into ~/.config/spotatui/plugins/<name>/ (a shallow clone) and
records it in ~/.config/spotatui/plugins.lock. update fast-forwards each clone to the remote's
latest commit. Restart spotatui after installing or updating for changes to take effect, and bind
any commands the plugin registers under plugin_commands in config.yml.
Single-file plugins you drop into plugins/ by hand are not tracked in the lockfile; plugin list
shows them under "untracked".
The quickest start is spotatui plugin new <name>, which scaffolds a working directory plugin in
your config directory:
spotatui plugin new my-pluginThis writes ~/.config/spotatui/plugins/my-plugin/main.lua (with a require_api guard, a sample
command, and a suggested key binding) plus a README.md. Edit it, then git init and push to
share it.
A shareable plugin is a git repository with a main.lua (or init.lua) entry point at its root:
my-plugin/
main.lua -- entry point; runs at startup
lib.lua -- optional helper module, loaded with require("lib")
README.md -- document the command(s) and a suggested key binding
The repository name becomes the local plugin name (its last path segment, minus .git). Ship a
suggested key binding in your README rather than writing to the user's config.yml; command
names are decoupled from keys by design.
To help others find it, add the GitHub topic spotatui-plugin to your repository, and open a pull
request adding it to PLUGINS.md.
A global table named spotatui is available in every plugin.
spotatui.api_version- integer API version (currently4).
The scripting API is versioned and grows over time. If your plugin uses a feature added in a
particular version, declare it on the first line so users on an older spotatui get a clear
message instead of a cryptic attempt to call nil error:
spotatui.require_api(4)spotatui.require_api(n) raises a load error (requires spotatui scripting API v{n} ...) when
the running build's api_version is lower than n, which stops that plugin from loading while
leaving the others untouched. Calling it with a version your build supports is a no-op.
Register a callback with spotatui.on(event, fn). Passing an unknown event name raises an
error. Valid events:
| Event | Argument | Fires when |
|---|---|---|
start |
none | The app finishes its first render. |
quit |
none | The app is shutting down. |
track_change |
playback table or nil | The current track identity changes (by uri, or name as a fallback), including the first track. |
playback_state_change |
playback table or nil | Playing/paused state changes (no playback counts as not playing). |
seek |
playback table or nil | Same track, same play state, and progress jumps backward by more than 1.5s or forward by more than 6.5s. Forward jumps inside that window are treated as normal Connect polling, not seeks. |
volume_change |
playback table or nil | The device volume percentage changes. |
queue_change |
none | The queue contents change. |
You can register multiple callbacks for the same event.
These return a snapshot of the cached state. Snapshots are refreshed before callbacks run.
spotatui.playback()- playback table, ornilwhen there is no playback.spotatui.current_track()- track table, ornil.spotatui.devices()- array of device tables.
The playback table has these fields:
{
track = { uri, name, artists = { ... }, album, duration_ms } or nil,
is_playing = bool,
progress_ms = number,
shuffle = bool,
repeat = "off" | "track" | "context",
volume_percent = number or nil,
device = { id, name, kind, is_active, volume_percent } or nil,
}
Actions are queued and applied by the app on the next opportunity; they do not return a result. Every action follows the exact same code path as the equivalent keybinding, including native streaming fast paths (librespot) when the native player is active.
spotatui.play()- resume playback. No-op if already playing.spotatui.pause()- pause playback. No-op if already paused.spotatui.next()- skip to the next track.spotatui.previous()- go to the previous track, or restart the current track when more than 3 seconds in (matching the previous-track key behaviour).spotatui.seek(ms)- seek to a position in milliseconds.spotatui.set_volume(pct)- set volume; clamped to 0-100.spotatui.shuffle(on)- set shuffle to the desired state. No-op if already in that state.spotatui.search(query)- run a search and open the Search screen.spotatui.notify(msg, ttl_secs?)- show a status message (default ttl 4 seconds).
spotatui.log(msg)- write an info-level line to the app log.
spotatui.json_decode(json)- parse a JSON string into Lua tables, strings, numbers, booleans, and nil-compatible values. Invalid JSON raises a Lua error.spotatui.json_encode(value)- serialize a Lua value to a compact JSON string. Values that cannot be represented as JSON, such as functions or userdata, raise a Lua error.
JSON null decodes to a light userdata sentinel, not Lua nil, and the sentinel is truthy in
Lua. To detect it, compare against a known null value:
local NULL = spotatui.json_decode("null")
local decoded = spotatui.json_decode('{"artist":null}')
if decoded.artist == NULL then
-- field was present but null
endlocal body = spotatui.json_encode({
track = "spotify:track:...",
rating = 5,
})
local decoded = spotatui.json_decode('{"ok":true,"items":[1,2]}')
spotatui.log("first item: " .. decoded.items[1])HTTP runs asynchronously. Calls return immediately; the callback runs on a later UI tick after
the response arrives. Only http:// and https:// URLs are accepted.
spotatui.http_get(url, callback)- send a GET request.spotatui.http_post(url, body, headers, callback)- send a POST request.bodyis a string.headersmust be a table of string keys and string values, ornilfor no headers. The four-argument form is required, so passnilwhen you do not need headers.
Callbacks receive callback(resp, err):
- Success:
resp = { status = number, ok = bool, body = string },err = nil. - Transport failure such as DNS, timeout, or connection failure:
resp = nil,err = string. - HTTP 4xx and 5xx responses are not transport failures. They call the success path with
resp.ok = false.
Response bodies are decoded with lossy UTF-8 conversion. In-flight requests are dropped when spotatui exits.
spotatui.on("track_change", function(pb)
if not pb or not pb.track then
return
end
local url = "https://example.com/lyrics?uri=" .. pb.track.uri
spotatui.http_get(url, function(resp, err)
if err then
spotatui.notify("lyrics fetch failed: " .. err, 4)
return
end
if resp.ok then
local parsed = spotatui.json_decode(resp.body)
spotatui.popup("Lyrics", parsed.lines)
else
spotatui.notify("lyrics service returned " .. resp.status, 4)
end
end)
end)local body = spotatui.json_encode({ event = "track_started" })
spotatui.http_post(
"https://example.com/webhook",
body,
{ ["content-type"] = "application/json" },
function(resp, err)
if err then
spotatui.log("webhook failed: " .. err)
elseif not resp.ok then
spotatui.log("webhook returned " .. resp.status)
end
end
)spotatui.register_command(name, fn) registers a named, callable action. The name must be a
non-empty string with no whitespace. Registering the same name twice (from any plugin) raises a
Lua error at load time.
spotatui.register_command("toggle_lyrics", function()
spotatui.notify("lyrics toggled", 3)
end)To bind a command to a key, add a plugin_commands section to config.yml:
plugin_commands:
toggle_lyrics: "ctrl-l"
show_stats: "ctrl-g"Each entry maps a command name to a key string. The key string uses the same format as the
built-in keybindings (e.g. ctrl-l, alt-x, f1, space). Entries are silently skipped when
the key string is invalid, the key is a reserved navigation key, or the key already has a named
action bound to it. The remaining entries are loaded normally.
When the bound key is pressed, the corresponding command callback fires after the current key handler returns. An error in the callback is reported as a highlighted status message (6-second ttl) and logged, but the command stays registered -- a transient failure does not permanently unbind a key.
Plugin authors should document a suggested binding in their plugin rather than shipping one in config. Command names are decoupled from keys by design: the user decides which key to use.
spotatui.set_playbar(text) sets a persistent text segment for the calling plugin, shown in
the playbar title as " | {text}" after any status message. Each plugin has its own segment
slot; calling set_playbar again replaces it. Pass nil to clear the segment.
spotatui.on("track_change", function(pb)
if pb and pb.track then
spotatui.set_playbar(pb.track.name)
else
spotatui.set_playbar(nil)
end
end)The segment persists until the plugin explicitly clears it. Multiple plugins each show their own segment in alphabetical plugin-name order.
spotatui.popup(title, lines) opens a centered modal dialog. The dialog overlays every
screen, including the help menu and queue. Press j/Down to scroll down, k/Up to scroll
up, and Esc or q to close. All other keys are swallowed while the popup is open.
lines can be:
- A single string.
- An array where each item is a string or a table
{ text, fg?, bold?, italic? }.fgis a color string in the same format asconfig.ymltheme values (e.g."Red","Magenta","0, 200, 200").boldanditalicare booleans (defaultfalse).- Missing
text, an unparseable color, or a non-string/non-table item raises a Lua error.
spotatui.popup("Track info", {
{ text = "Now playing", bold = true },
{ text = "Song title here", fg = "Cyan" },
"",
"Press Esc to close",
})spotatui.set_theme(tbl) applies runtime color overrides to the active theme. Keys are
theme field names and values are color strings. Changes are applied immediately and affect all
subsequent renders. They are never written back to config.yml -- they are runtime-only and
reset on app restart.
Valid field names: active, banner, error_border, error_text, hint, hovered,
inactive, playbar_background, playbar_progress, playbar_progress_text, playbar_text,
selected, text, background, header, highlighted_lyrics, analysis_bar,
analysis_bar_text.
Color string format is the same as in config.yml (named ANSI color or "r, g, b").
An unknown field name or an invalid color raises a Lua error.
spotatui.set_theme({
playbar_text = "Magenta",
hint = "0, 200, 0",
})Plugin code can never crash the app. If a callback raises an error or panics, the error is logged, a highlighted status message is shown in the playbar, and that one callback is disabled (one strike). Other callbacks, including other callbacks for the same event, keep running.
Plugin errors are shown using the theme's error color and stay visible for 6 seconds.
Normal notifications (e.g. a "Now playing" message from spotatui.notify) cannot overwrite
a live plugin error -- the error is shown first, and the notification takes effect only after
the error expires. A later plugin error always replaces an earlier one immediately.
spotatui.on("track_change", function(pb)
if pb and pb.track then
spotatui.notify("Now playing: " .. pb.track.name .. " by " .. table.concat(pb.track.artists, ", "), 4)
end
end)
spotatui.on("start", function()
spotatui.log("plugins loaded, api version " .. spotatui.api_version)
end)