A VLC Lua extension is a .lua file placed in VLC's extension directory. It exposes a handful of required lifecycle hooks that VLC calls at specific moments, plus optional event callbacks you register at runtime.
The entry points VLC looks for are:
-- Mandatory: metadata VLC reads before loading function descriptor() return { title = "My Extension", version = "1.0", author = "You", url = "https://example.com", description = "What it does", capabilities = { "input-listener", "meta-listener", "playing-listener" }, } end -- Called when user activates extension from Tools > Extensions function activate() -- open dialog, start timers, etc. end -- Called when user deactivates or VLC quits function deactivate() -- clean up resources end -- Optional — called ~10 times/second if extension is active function trigger() -- polling hook for UI refresh end
descriptor() unlock the corresponding event callbacks. You must list "input-listener" to receive input_changed(), "playing-listener" for playing_changed(), and so on.-- Linux ~/.local/share/vlc/lua/extensions/myext.lua -- macOS ~/Library/Application Support/org.videolan.vlc/lua/extensions/myext.lua -- Windows %APPDATA%\vlc\lua\extensions\myext.lua
After adding or modifying an extension file, restart VLC or go to Tools → Extensions and reload. There is no hot-reload mechanism in VLC 3.x; VLC 4 adds limited in-place reload via the Lua console.
VLC fires specific Lua callbacks when playback state changes. These are free functions you define at module level — not methods to register on an object. The runtime calls them automatically when you've declared the right capabilities.
| Callback | Capability needed | When it fires |
|---|---|---|
input_changed() | "input-listener" | A new item starts loading (before it plays) |
playing_changed(i) | "playing-listener" | Play state changes — i is 0=stopped, 1=opening, 2=buffering, 3=playing, 4=paused, 5=end, 6=error |
meta_changed() | "meta-listener" | Track metadata becomes available or changes |
status_changed(s) | "status-listener" | Fires on seek/rate/audio-volume change |
intf_event(event) | — | Low-level input events (chapter change, EPG, …) |
function input_changed() local item = vlc.input.item() if not item then return end -- nothing loaded local metas = item:metas() -- table of known meta fields vlc.msg.dbg("Now loading: " .. (metas["title"] or item:name())) end -- i: 0=stopped 1=opening 2=buffering 3=playing 4=paused 5=end 6=error function playing_changed(i) local labels = { "stopped", "opening", "buffering", "playing", "paused", "ended", "error" } vlc.msg.dbg("State → " .. (labels[i + 1] or "unknown")) if i == 3 then -- playing refresh_ui() elseif i == 5 then -- ended — auto-advance logic here on_track_ended() end end function meta_changed() local item = vlc.input.item() if item then local m = item:metas() vlc.msg.dbg("Artist: " .. (m["artist"] or "unknown")) end end
The vlc.input module exposes the current input descriptor. Most calls return nil when nothing is playing, so always guard:
local function get_info() local input = vlc.object.input() if not input then return nil end return { time = vlc.var.get(input, "time"), -- microseconds length = vlc.var.get(input, "length"), -- microseconds position = vlc.var.get(input, "position"), -- 0.0 – 1.0 rate = vlc.var.get(input, "rate"), -- 1.0 = normal speed state = vlc.var.get(input, "state"), -- integer 0-6 } end -- Convert microseconds to "HH:MM:SS" local function fmt_time(us) local s = math.floor(us / 1e6) local h = math.floor(s / 3600) local m = math.floor((s % 3600) / 60) local ss = s % 60 return string.format("%02d:%02d:%02d", h, m, ss) end
vlc.object.input() and vlc.input.item() are different things. The former returns the low-level input thread object for use with vlc.var.get(); the latter returns the media item with its metadata. Both can be nil.VLC's vlc.dialog module lets you create native-looking windows with a grid-based layout system. Widgets are placed by specifying column and row positions, and you can span multiple cells.
local dlg -- module-level handle local lbl_time, btn_play, slider_vol function activate() dlg = vlc.dialog("Now Playing") -- window title -- add_label(text, col, row, col_span, row_span) dlg:add_label("<b>Track:</b>", 1, 1, 1, 1) lbl_track = dlg:add_label("—", 2, 1, 3, 1) dlg:add_label("<b>Time:</b>", 1, 2, 1, 1) lbl_time = dlg:add_label("00:00:00", 2, 2, 3, 1) -- Buttons: add_button(caption, callback_fn, col, row, col_span, row_span) btn_play = dlg:add_button("⏸ Pause", on_play_pause, 1, 3, 2, 1) btn_stop = dlg:add_button("⏹ Stop", on_stop, 3, 3, 2, 1) -- Spin box: add_spin_icon(min, max, step, value, callback, col, row, cs, rs) dlg:add_label("Rate:", 1, 4) spin_rate = dlg:add_spin_icon(1, 400, 25, 100, on_rate_change, 2, 4, 3, 1) dlg:show() end
| Method | Returns | Description |
|---|---|---|
add_label(text, …) | widget | Static or HTML-formatted text label |
add_button(caption, fn, …) | widget | Clickable button, fn called on click |
add_text_input(text, …) | widget | Single-line editable text field |
add_password(text, …) | widget | Text field with hidden characters |
add_check_box(text, state, fn, …) | widget | Checkbox — fn called on toggle |
add_dropdown(…) | widget | Drop-down selector; populate with :add_value() |
add_list(…) | widget | Multi-item list; populate with :add_value() |
add_spin_icon(min, max, step, val, fn, …) | widget | Numeric spin box |
add_text_area(text, …) | widget | Multi-line text display / input |
add_html(html, …) | widget | Rendered HTML widget (Qt WebEngine) |
add_image(path, fn, …) | widget | Display an image from a local path |
function refresh_ui() if not dlg then return end local item = vlc.input.item() if item then local m = item:metas() local title = m["title"] or item:name() lbl_track:set_text("<b>" .. vlc_escape(title) .. "</b>") end local input = vlc.object.input() if input then local t = vlc.var.get(input, "time") lbl_time:set_text(fmt_time(t)) end dlg:update() -- flush pending Qt repaints end -- Called by trigger() ~10×/s to keep the time label live function trigger() refresh_ui() end
dlg:update() after batching multiple widget updates. It flushes the pending Qt repaint queue in one pass, which is far cheaper than letting each set_text() trigger its own repaint.drop = dlg:add_dropdown(1, 5, 4, 1) -- add_value(label, id) — id is returned by get_value() drop:add_value("Subtitles off", 0) drop:add_value("English", 1) drop:add_value("French", 2) local function on_sub_change() local id = drop:get_value() -- returns the numeric id local input = vlc.object.input() if input then vlc.var.set(input, "spu-es", id) end end
All playback control is done through vlc.var.set() on the input object, or via the higher-level vlc.playlist convenience wrappers.
local function seek_to(seconds) local input = vlc.object.input() if not input then return end -- "time" is in microseconds vlc.var.set(input, "time", seconds * 1e6) end local function seek_relative(delta_s) local input = vlc.object.input() if not input then return end local t = vlc.var.get(input, "time") vlc.var.set(input, "time", t + delta_s * 1e6) end local function seek_position(frac) -- 0.0 – 1.0 local input = vlc.object.input() if not input then return end vlc.var.set(input, "position", math.max(0, math.min(1, frac))) end
local function set_rate(r) -- 0.25–4.0; 1.0 = normal local input = vlc.object.input() if input then vlc.var.set(input, "rate", math.max(0.25, math.min(4.0, r))) end end -- Callback for the spin_rate widget defined earlier function on_rate_change() local pct = spin_rate:get_value() -- 25–400 set_rate(pct / 100) end
local function get_volume() -- Returns 0–512; 256 = 100%, 512 = 200% (amplified) return vlc.volume.get() end local function set_volume(pct) -- 0–200 vlc.volume.set(math.floor(pct * 2.56)) -- convert to 0-512 range end local function toggle_mute() vlc.volume.toggle_mute() end
function on_play_pause() local input = vlc.object.input() if not input then return end local state = vlc.var.get(input, "state") if state == 3 then -- currently playing → pause vlc.playlist.pause() btn_play:set_text("▶ Play") else -- paused/stopped → play vlc.playlist.play() btn_play:set_text("⏸ Pause") end end function on_stop() vlc.playlist.stop() btn_play:set_text("▶ Play") lbl_time:set_text("00:00:00") dlg:update() end
The vlc.playlist and vlc.sd modules give you full programmatic control over the playlist — reading items, appending, removing, and reordering.
-- Read current playlist local items = vlc.playlist.get("playlist", false) -- items is a table; each has .id, .name, .path, .duration, .nb_played for _, it in ipairs(items.children) do vlc.msg.dbg(it.name .. " [" .. it.id .. "]") end -- Jump to a specific item by id vlc.playlist.gotoitem(item_id) -- Append a new URI vlc.playlist.add({ { path = "file:///home/user/music/track.flac", name = "My Track", meta = { artist = "Artist Name", album = "Album" } } }) -- Delete item by id vlc.playlist.delete(item_id) -- Shuffle / sort vlc.playlist.sort("name") -- sort key: "id"|"title"|"artist"|"album"|"duration" vlc.playlist.random(true) -- enable shuffle mode
input_changed() or playing_changed() — you're already inside VLC's input lock. Doing so causes a deadlock. Schedule such work via a flag read on the next trigger() poll.local pending_action = nil function playing_changed(i) if i == 5 then -- track ended pending_action = "skip_to_bookmark" -- DO NOT call playlist APIs here! end end function trigger() if pending_action then local act = pending_action pending_action = nil -- clear before executing if act == "skip_to_bookmark" then vlc.playlist.gotoitem(bookmark_id) end end refresh_ui() end
The following is a minimal but fully functional extension that displays a floating HUD with track info, a live time counter, play/pause/stop buttons, and a playback rate spinner.
-- hud.lua — minimal now-playing HUD local dlg, lbl_track, lbl_time, btn_pp, spin_rate local pending = nil function descriptor() return { title = "Now Playing HUD", version = "1.0", author = "dev", capabilities = { "input-listener", "playing-listener", "meta-listener" }, } end local function fmt(us) local s = math.floor((us or 0) / 1e6) return string.format("%02d:%02d:%02d", math.floor(s/3600), math.floor(s%3600/60), s%60) end local function refresh() if not dlg then return end local item = vlc.input.item() local input = vlc.object.input() if item then lbl_track:set_text(item:metas()["title"] or item:name()) end if input then lbl_time:set_text(fmt(vlc.var.get(input, "time"))) end dlg:update() end function activate() dlg = vlc.dialog("HUD") lbl_track = dlg:add_label("—", 1, 1, 4, 1) lbl_time = dlg:add_label("00:00:00", 1, 2, 4, 1) btn_pp = dlg:add_button("⏸", on_pp, 1, 3, 1, 1) dlg:add_button("⏹", on_stop, 2, 3, 1, 1) spin_rate = dlg:add_spin_icon(25, 400, 25, 100, on_rate, 3, 3, 2, 1) dlg:show() refresh() end function deactivate() if dlg then dlg:delete() end dlg = nil end function trigger() refresh() end function input_changed() refresh() end function meta_changed() refresh() end function playing_changed(i) refresh() end function on_pp() local inp = vlc.object.input() if not inp then return end if vlc.var.get(inp, "state") == 3 then vlc.playlist.pause(); btn_pp:set_text("▶") else vlc.playlist.play(); btn_pp:set_text("⏸") end end function on_stop() vlc.playlist.stop() btn_pp:set_text("▶"); lbl_time:set_text("00:00:00"); dlg:update() end function on_rate() local inp = vlc.object.input() if inp then vlc.var.set(inp, "rate", spin_rate:get_value() / 100) end end
VLC's Lua API returns nil liberally when there's no active input, no current item, or no playlist. Index a nil value and the whole extension crashes silently. Wrap every access:
local input = vlc.object.input() if not input then return end -- always bail early
As noted above, calling vlc.playlist.* inside playing_changed() or input_changed() deadlocks. Use the deferred-action flag pattern via trigger().
Label text is treated as Qt rich text when it contains HTML tags. Filenames with <, >, or & will corrupt the display. Escape before calling set_text():
local function esc(s) return (s:gsub("&", "&") :gsub("<", "<") :gsub(">", ">")) end
VLC schedules trigger() at roughly 10 Hz, but it skips calls if VLC's main thread is busy. Don't use it for precise timing — use it only for UI refresh polling.
Always dlg:delete() in deactivate() and set dlg = nil. If the user reopens the extension, activate() is called again and you need to create a fresh dialog, not reuse a stale handle.
vlc.msg.dbg() output while developing. This is your primary debugging tool — there is no step debugger for VLC Lua extensions.