Event Listeners, Dialogs and Real-Time Control in Lua

API version VLC 3.x / 4.x
Language Lua 5.1
Difficulty Intermediate

01 Extension architecture overview

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:

lua — extension skeleton
-- 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
Capabilities declared in descriptor() unlock the corresponding event callbacks. You must list "input-listener" to receive input_changed(), "playing-listener" for playing_changed(), and so on.

Where to place your extension

paths by OS
-- 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.

02 Event listeners & the input object

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.

The five event callbacks

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, …)
lua — event callbacks
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

Reading the input object

The vlc.input module exposes the current input descriptor. Most calls return nil when nothing is playing, so always guard:

lua — reading playback state
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.

03 Building dialogs with vlc.dialog

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.

Creating and showing a dialog

lua — dialog creation
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

Available widget types

MethodReturnsDescription
add_label(text, …)widgetStatic or HTML-formatted text label
add_button(caption, fn, …)widgetClickable button, fn called on click
add_text_input(text, …)widgetSingle-line editable text field
add_password(text, …)widgetText field with hidden characters
add_check_box(text, state, fn, …)widgetCheckbox — fn called on toggle
add_dropdown(…)widgetDrop-down selector; populate with :add_value()
add_list(…)widgetMulti-item list; populate with :add_value()
add_spin_icon(min, max, step, val, fn, …)widgetNumeric spin box
add_text_area(text, …)widgetMulti-line text display / input
add_html(html, …)widgetRendered HTML widget (Qt WebEngine)
add_image(path, fn, …)widgetDisplay an image from a local path

Updating widgets from callbacks

lua — updating dialog state
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
Call 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.

Populating lists and dropdowns

lua — dropdown population
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

04 Real-time playback control

All playback control is done through vlc.var.set() on the input object, or via the higher-level vlc.playlist convenience wrappers.

Seeking

lua — seek operations
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

Playback rate

lua — rate control
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

Audio volume

lua — volume
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

Play / Pause / Stop

lua — transport controls
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

05 Playlist manipulation

The vlc.playlist and vlc.sd modules give you full programmatic control over the playlist — reading items, appending, removing, and reordering.

lua — playlist operations
-- 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
!
Never call playlist APIs from within 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.

Safe deferred execution pattern

lua — deferred action pattern
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

06 Complete working example

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.

lua — hud.lua (complete)
-- 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

07 Common pitfalls

1 — nil-guard everything

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:

lua
local input = vlc.object.input()
if not input then return end   -- always bail early

2 — No playlist calls inside event callbacks

As noted above, calling vlc.playlist.* inside playing_changed() or input_changed() deadlocks. Use the deferred-action flag pattern via trigger().

3 — HTML entities in labels

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():

lua
local function esc(s)
  return (s:gsub("&", "&amp;")
            :gsub("<",  "&lt;")
            :gsub(">",  "&gt;"))
end

4 — trigger() is not guaranteed 10 Hz

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.

5 — Dialog handles are nil after deactivate

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.

Use Tools → Messages (set verbosity to Debug) to see vlc.msg.dbg() output while developing. This is your primary debugging tool — there is no step debugger for VLC Lua extensions.