docs / plugins
Lua plugin API
rs-pug embeds Lua 5.4 via mlua, no system Lua needed. Each .lua file is an isolated plugin. Pick a topic below, each page covers exactly one area, straight from src/plugins.rs.
start: loading and limits
Drop .lua files into ~/.config/rs-pug/plugins (non recursive). File stem becomes the plugin name in logs. Minimal plugin:
plugin = {}
function plugin.on_key(key, state)
if key == "char:h" then
return { consume = true,
flash = "tab: " .. state.active_tab,
flash_seconds = 3 }
end
end
return plugin
How load() treats each file, in order:
| step | what the code does |
|---|---|
| scan | fs::read_dir on plugins_dir, only .lua files. Order is filesystem order, never assume alphabetical. |
| state | Fresh Lua::new() per file, full stdlib, fully isolated globals. |
| eval | Chunk runs under exec timeout. Use returned table, else global plugin, else error missing plugin table. |
| env | Globals set: ALLOW_LUA_UI_CHANGES (bool copy of config flag) and plugin (your table). |
Execution budget, enforced per single hook call via a VM instruction hook: 250 ms wall time, checked every 10,000 instructions. Overrun aborts with: plugin exceeded execution time limit. Hot reload destroys every Lua state and rebuilds from scratch on any .lua change or on config change to plugins_enabled, plugins_dir or allow-lua-ui-changes, and logs a plugins hot-reloaded warning.
[general]
plugins_enabled = true
plugins_dir = "~/.config/rs-pug/plugins"
[lua]
allow-lua-ui-changes = false
With allow-lua-ui-changes false, on_ui_config, on_ui_sections, on_ui_inject and on_ui_update never run, and ui.layout patches from keys or events are skipped. Everything on the other tabs works in both modes.
search hooks: transform pipeline
Three hooks rewrite data in flight. They run through run_fold: plugin 1 output becomes plugin 2 input, in load order. Return nil to pass through untouched.
| hook | input | return | fires |
|---|---|---|---|
| on_search_query(q) | string, raw typed query | string | once before Discover, Albums, YouTube, SoundCloud or Sonum search |
| on_search_results(songs) | Song list | Song list, filter, sort or extend | once after results arrive, before display |
| on_song_start(song) | Song about to play | Song, e.g. swapped webpage_url | once per track start |
Song shape, all three fields required: id (URL or file path), title, webpage_url for playback, plus optional uploader string and duration number in seconds. Code level detail: inside run_fold every failure mode is silent. Lua error, timeout, bad input serialization and bad return shape all skip that plugin with zero warning entry (the code literally discards the error). A typo in a Song field means your return is dropped and the previous value flows on. Test transforms in standalone lua5.4 first.
function plugin.on_search_query(query)
return query .. " official audio"
end
function plugin.on_search_results(songs)
local out = {}
for _, s in ipairs(songs) do
if not s.duration or s.duration <= 600 then
out[#out + 1] = s
end
end
return out
end
keys + events: dispatch
on_key(key, state) fires on every keypress no modal view claimed first (tag editor, option editing, context menu, delete confirm, help, palette and search typing all intercept before plugins). on_event(event, state) fires on every core event including progress, several times per second during playback, so check event.kind first thing. Both return PluginDispatch, nil means do nothing.
| dispatch field | type | effect |
|---|---|---|
| consume | bool, default false | true stops built in key handling (keys only) |
| flash | string | status bar message |
| flash_seconds | u64 | display time, default about 4 |
| core_actions | list, see below | all executed in load order |
| ui | patch, see below | set_tab, set_search_query, set_album_search_query, set_focus (search, results, queue), set_search_mode bool, set_selected_result, set_selected_album_result, set_selected_queue (0 based, clamped), layout patch |
Core actions, tagged by type field: search {query}, search_albums {query}, seek {seconds i32, negative goes back}, toggle_pause, toggle_mute, volume_up, volume_down, next, prev, set_volume {value u8}, play_url {url, optional title}, raw_mpv {command, any JSON, forwarded to mpv IPC unvalidated}.
Event shape: kind string, message string or nil (track title on started, error text on error), value number or nil (result count on search_done and album_search_done, position seconds on progress). Anything else arrives as kind event with no payload, compare state fields instead. Key labels come from describe_key_event_with_modifiers, exact mapping:
| physical key | label |
|---|---|
| letter or symbol | char:x, case follows Shift for a-z (Shift+z gives char:Z) |
| enter / esc / tab / backspace | enter, esc, tab, backspace |
| arrows | left, right, up, down |
| pageup / pagedown / Fx | page_up, page_down, f1 to f12 |
| home / end / delete / rest | other, all share one label |
Ctrl and Alt never reach on_key, Ctrl+r and r both arrive as char:r. Use config.toml [keybinds] with C, M, S prefixes for modifiers. Shift alias, from describe_key_event_labels plus dispatch_key_with_aliases: ASCII letters get a second toggled case label. The engine queries every plugin with the primary label first, and only if no plugin produced any effect does it retry with the alias. Effect means consume, flash, flash_seconds, any core action, any ui setter, or any layout patch field. In practice handle one case and the other is tried for free.
function plugin.on_key(key, state)
if key == "char:[" then
return { consume = true,
core_actions = { { type = "seek", seconds = -15 } } }
end
if key == "char:]" then
return { consume = true,
core_actions = { { type = "seek", seconds = 15 } } }
end
end
function plugin.on_event(event, state)
if event.kind == "started" then
return { flash = "Now: " .. event.message,
flash_seconds = 3 }
end
end
tabs + panels: extra surfaces
These two hooks work with or without allow-lua-ui-changes. Both are polled every frame while state keeps changing. Return nil when idle.
| hook | return | merge |
|---|---|---|
| on_tabs(state) | PluginTab list: id and title required, icon optional | concatenated across plugins, no id dedup, errors skipped silently |
| on_ui_panels(state) | PluginPanel list, see below | concatenated, errors skipped silently |
PluginPanel: title required, target optional (main, results, queue, overlay, missing behaves like main). Panels on main, results or queue render inside the results or queue area only while a plugin tab or custom tab is active, never on stock tabs. Overlay panels always float top right. Legacy lines list still works: when items is empty it is auto converted to text items by collect_ui_panels. Eight item types, tagged by type: text {text}, info {text, info color}, option {key, value}, stat {label, value}, separator {}, header {text, bold}, keybind {key, action}, progress {optional label, percent f64 clamped 0-100, 10 segment bar}.
function plugin.on_tabs(state)
return { { id = "stats", title = "Stats" } }
end
function plugin.on_ui_panels(state)
return { { title = "Now", target = "overlay",
items = {
{ type = "stat", label = "state", value = state.player_state },
{ type = "stat", label = "queue",
value = tostring(state.queue_len) },
{ type = "separator" },
{ type = "progress", label = "buffer", percent = 87 },
} } }
end
layout + ui: reshape the screen
All four hooks need allow-lua-ui-changes true or they are never called. Shapes are auto detected where noted.
| hook | return | cadence |
|---|---|---|
| on_ui_config(state) | {tabs, layout}: remove list, order list, rename map id to title/icon, custom tabs; plus full layout | once at startup and after every reload, replaces main tab set |
| on_ui_update(state) | layout only. Flat table or {layout} wrapper, detected by presence of layout or tabs key, both shapes accepted | every frame on state change |
| on_ui_sections(state) | map of section id to item lists, feeds custom_sections content | every frame with inject |
| on_ui_inject(state) | {results_top, results_bottom, queue_top, queue_bottom, statusbar_extra}, each an item list | every frame, renders on any tab |
Layout fields, all optional, set fields win over current values: queue_width_percent 10-90, visualizer_height 0-10 rows, show_progress_bar, show_volume_bar, show_statusbar, show_keybind_hints bools, tab_bar_position top bottom left right, tabs_width 12-40 cols, queue_position left right, hide list of fixed elements (visualizer, progress_bar, volume_bar, statusbar, keybind_hints), custom_sections list, hide_sections and show_sections lists for your own sections. Do not mix the two systems: hide targets fixed elements, hide_sections targets your custom ids. Custom section: id required non empty unique, position default below_player (above_player, below_player, left, right), width cols for left/right, height rows default 3, content field reserved and currently unused, real content comes only from on_ui_sections under the same id. Custom tab: id and title required, icon optional, position 1 based and clamped. Removing every main tab without replacement restores defaults with a warning.
if not ALLOW_LUA_UI_CHANGES then return plugin end
function plugin.on_ui_config(state)
return { tabs = { custom = {
{ id = "dash", title = "Dash", position = 2 } } },
layout = { queue_width_percent = 35,
show_keybind_hints = false,
custom_sections = {
{ id = "clock", position = "above_player" } } } }
end
function plugin.on_ui_sections(state)
return { clock = {
{ type = "text", text = os.date("%H:%M") } } }
end
function plugin.on_ui_update(state)
if state.queue_len == 0 then
return { queue_width_percent = 20 }
end
return { queue_width_percent = 35 }
end
state: read only snapshot
Every state taking hook receives PluginUiState, built by from_app. Never mutate it, express changes through return values. All 13 fields:
| field | type | notes |
|---|---|---|
| active_tab | string | discover, albums, library, local, options, or custom id. Active custom tab wins over main tabs. |
| active_plugin_tab | string or nil | id from on_tabs when one is active |
| active_custom_tab | string or nil | id from on_ui_config when one is active |
| active_tab_index | number, 0 based | covers main plus plugin tabs |
| current_layout | object | queue_width_percent, visualizer_height, tab_bar_position, tabs_width, queue_position |
| visible_sections | string list | currently non hidden custom section ids |
| player_state | string | label like playing, paused, idle, searching |
| volume | number 0-100 | u8 in code |
| muted | bool | |
| repeat_mode | string | off, one, all, lowercase label |
| search_query | string | Discover field text |
| album_search_query | string | Albums field text |
| queue_len | number | track count in queue |
Typical pattern: branch on state, return nil otherwise. Frame polled hooks run only while state changed since last frame, so cheap reads are fine but keep IO out.
function plugin.on_ui_update(state)
if state.active_tab == "queue" then
return { queue_width_percent = 60 }
end
if state.muted then
return { show_volume_bar = false }
end
end
merging: many plugins, one result
Load order is filesystem order and not guaranteed. Rules below come straight from merge_dispatch, merge_ui_config, merge_layout_config and the collectors. Note the asymmetry: dispatch scalars are first wins, layout scalars are last wins.
| area | rule in code |
|---|---|
| search / song hooks | pipeline via run_fold, each output feeds the next plugin |
| consume | logical OR, one true is enough |
| flash, flash_seconds, ui setters | first plugin that sets the field wins, later values ignored |
| core_actions | concatenated in load order, all executed |
| ui.layout scalars | last plugin that sets the field wins, opposite of the rest |
| hide_sections / show_sections | concatenated everywhere they appear |
| on_tabs / panels / inject | concatenated lists, no dedup |
| config remove / custom / sections | concatenated; sections map extends so last wins per section id |
| config order | last non empty order list wins |
| config rename | hashmap extend, last wins per tab id |
| layout config scalars | last Some overwrites, via merge_layout_config |
Cooperation advice from the code: give each field exactly one owner plugin. Return nil instead of empty tables from frame hooks so last wins merges do not get clobbered by accident.
debug: warnings and safety
Warning queue holds 100 entries, oldest dropped first. Only the newest shows in the status bar. Format, from PluginWarning label: Lua WARN [name.hook]: message. What warns:
| situation | level |
|---|---|
| cannot read plugin dir or file | warning or error with file path |
| load failed (syntax, timeout) | error, remaining files still load |
| missing plugin table | error, file skipped |
| hook is not a function | warning, treated as absent |
| UI hook runtime or timeout fail | error_hook call failed |
| bad dispatch or layout shape | error_invalid dispatch return / invalid return shape |
| unknown tab ids, bad positions, clamped numbers, emptied tab set | warning, renderer falls back |
What stays silent by design: runtime errors and timeouts inside on_search_query, on_search_results, on_song_start, on_key, on_event, on_tabs and on_ui_panels skip that plugin with no entry anywhere, and shape mismatches in the three transform hooks silently keep the previous value. If such a hook seems dead, run rs-pug --debug and read ~/.config/rs-pug/rs-pug.log, then test the fragment in standalone lua5.4. Trust boundary: full stdlib including io and os.execute, same user as the player process, no sandbox beyond the 250 ms budget, .lua files execute on drop with no confirmation, raw_mpv forwards raw JSON to mpv IPC. Frame hooks (on_event progress, on_ui_update, on_ui_sections, on_tabs, on_ui_panels) run constantly: filter on event.kind first, do no IO, allocate little.