From 6b188b8dc4bfdfee9c4200777bfa0de037b346a1 Mon Sep 17 00:00:00 2001 From: Alexandre Delgado Date: Fri, 29 May 2026 15:21:27 +0100 Subject: [PATCH 1/4] options: add osd-lua command line option Add a command line option to activate the Lua OSD. --osd-lua=yes to activate, --osd-lua=no to deactivate, same as other similar options. Default value is false. Signed-off-by: Alexandre Delgado Co-authored-by: Ricardo Fonseca --- options/options.c | 2 ++ options/options.h | 1 + 2 files changed, 3 insertions(+) diff --git a/options/options.c b/options/options.c index 48baef13ff731..002cd1c624660 100644 --- a/options/options.c +++ b/options/options.c @@ -803,6 +803,7 @@ static const m_option_t mp_opts[] = { {"", OPT_SUBSTRUCT(video_equalizer, mp_csp_equalizer_conf)}, {"use-filedir-conf", OPT_BOOL(use_filedir_conf)}, + {"osd-lua", OPT_BOOL(lua_load_osd)}, {"osd-level", OPT_CHOICE(osd_level, {"0", 0}, {"1", 1}, {"2", 2}, {"3", 3})}, {"osd-on-seek", OPT_CHOICE(osd_on_seek, @@ -1016,6 +1017,7 @@ static const struct MPOpts mp_default_opts = { .osd_on_seek = 1, .osd_duration = 1000, #if HAVE_LUA + .lua_load_osd = false, .lua_load_osc = true, .lua_load_ytdl = true, .lua_ytdl_format = NULL, diff --git a/options/options.h b/options/options.h index 014e41de6d27e..f3030033ff409 100644 --- a/options/options.h +++ b/options/options.h @@ -182,6 +182,7 @@ typedef struct MPOpts { char **script_files; char **script_opts; bool js_memory_report; + bool lua_load_osd; bool lua_load_osc; bool lua_load_ytdl; char *lua_ytdl_format; From f5cde362e014a9bcf3d2898d7e7c11f852e59980 Mon Sep 17 00:00:00 2001 From: Alexandre Delgado Date: Fri, 29 May 2026 15:22:20 +0100 Subject: [PATCH 2/4] player/lua: implement OSD layer in Lua A Lua script that draws every visual component of the OSD and provides several user options for easy customization just like osc.lua does. Configuration file for the Lua OSD goes in ~/.config/mpv/script-opts/osd.conf This script suppresses the OSD written in C when it is run. Signed-off-by: Alexandre Delgado Co-authored-by: Ricardo Fonseca --- player/lua/osd.lua | 725 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 725 insertions(+) create mode 100644 player/lua/osd.lua diff --git a/player/lua/osd.lua b/player/lua/osd.lua new file mode 100644 index 0000000000000..381cb3ed14782 --- /dev/null +++ b/player/lua/osd.lua @@ -0,0 +1,725 @@ +local assdraw = require 'mp.assdraw' +local msg = require 'mp.msg' +local opt = require 'mp.options' + +-- ----------------------------------------------- +-- Customization Options +-- ----------------------------------------------- + +local user_opts = { + -- Time OSD stays on screen after any change + duration = 1.0, + + -- Bar geometry + bar_width = 0.75, -- bar width (as fraction of screen width) + bar_height = 0.04, -- bar height (as fraction of screen height) + bar_x_offset = 0, -- horizontal position for bar counting from center of screen (as fraction of screen width) + bar_y_pos = 0.75, -- vertical position for bar counting from top of screen (as fraction of screen height) + bar_radius = 0, -- corner radius of the bar (as fraction of screen diagonal) + round_fill_end = true, -- toggle for rounding fill bar right corners + show_bar_outline = true, -- toggle for bar outline + outline_gap = 0.003, -- gap between bar and it's outline (as fraction of screen diagonal) + marker_width = 0.005, -- marker width (as fraction of bar width) + marker_height = 0.2, -- marker height (as fraction of bar height) + + -- Colors + bar_body_color = "#FFFFFF", -- color of the bar's section that isn't filled + bar_body_border_color = "#000000", -- color of the outer border of the bar's section that isn't filled + bar_fill_color = "#FFFFFF", -- color of the bar's filled section + bar_fill_border_color = "#000000", -- color of the outer border of the bar's filled section + bar_outline_color = "#FFFFFF", -- color of the bar's outline + bar_outline_border_color= "#000000", -- color of the outer border of the bar's outline + text_color = "#FFFFFF", -- color of the text of the property that is being changed (eg: volume, seek, etc) + text_border_color = "#000000", -- color of the text's border + text_shadow_color = "#000000", -- color of the text's shadow + icon_color = "#FFFFFF", -- color of the icon of the property that is being changed + icon_border_color = "#000000", -- color of the icon's border + text_box_color = "#FFFFFF", -- color of the text's background + text_box_border_color = "#000000", -- color of the text's box + + -- Alphas (aka opacity) + -- goes from 0 (fully opaque) to 255 (fully transparent) + bar_body_alpha = 255, -- alpha of the bar's section that isn't filled + bar_body_border_alpha = 0, -- alpha of the outer border of the bar's section that isn't filled + bar_fill_alpha = 0, -- alpha of the bar's filled section + bar_fill_border_alpha = 0, -- alpha of the outer border of the bar's filled section + bar_outline_alpha = 0, -- alpha of the bar's outline + bar_outline_border_alpha= 0, -- alpha of the outer border of the bar's outline + icon_border_alpha = 0, -- alpha of the icon border + text_alpha = 0, -- opacity of text + text_border_alpha = 0, -- opacity of text outline + text_shadow_alpha = 0, -- opacity of text shadow + icon_alpha = 0, -- opacity of icon + text_box_alpha = 200, -- opacity of text background + text_box_border_alpha = 255, -- opacity of text background border + + -- Text + font_size = 0.030, -- text size (as fraction of screen diagonal) + font = "", -- optional text font name + label_x_offset = 0.02, -- horizontal offset for label counting from left of screen (as fraction of screen width) + label_y_offset = 0.02, -- vertical offset for label counting from top of screen (as fraction of screen height) + + text_border = 0.002, -- size of text outline (as fraction of screen diagonal) + text_shadow = 0.001, -- size of text shadow (as fraction of screen diagonal) + + text_box = false, -- toggle for background square behind label + label_box_size = 0.5, -- controls how wide the background box is compared to the text + label_box_pad_x = 0.04, -- horizontal padding inside the text background box (as fraction of screen width) + label_box_pad_y = 0.02, -- vertical padding inside the text background box (as fraction of screen height) + + text_center = false, -- align label with center of screen + + -- Icons + show_icons_label = false, -- toggle for showing icon at start of label + show_icons_bar = true, -- toggle for showing icon next to bar + icon_size = 0.055, -- size of icon next to bar (as fraction of screen diagonal) + distance_bar = 0.020, -- horizontal distance from bar (as fraction of screen width) + icon_y_offset = -0.005, -- vertical offset from bar level (as fraction of screen height) + icon_volume = "🔈", -- icon for Volume + icon_mute = "🔇", -- icon for Muting + icon_play = "▶", -- icon for Playing + icon_pause = "⏸", -- icon for Pausing + icon_seek = "⏩", -- icon for Seek + icon_brightness = "🔆", -- icon for Brightness + icon_saturation = "◑", -- icon for Saturation + icon_contrast = "◐", -- icon for Contrast + + bar_when_pausing = false, -- toggle for showing seek bar when pausing + + -- Feature on/off switches + show_volume = true, -- toggle for showing changes in Volume + show_seek = true, -- toggle for showing changes when seeking + show_pause = true, -- toggle for showing state when pausing/ playing + show_brightness = true, -- toggle for showing changes in Brightness + show_saturation = true, -- toggle for showing changes in Saturation + show_contrast = true, -- toggle for showing changes in Contrast +} + + +-- 3. Runtime state +local state = { + hide_timer = nil, +} + +-- ----------------------------------------------- +-- Utility Helpers +-- ----------------------------------------------- + +local function clamp(value, min_v, max_v) + if value < min_v then return min_v end + if value > max_v then return max_v end + return value +end + +local function to_ratio(value, max_value) + if max_value == 0 then return 0 end + return clamp(value / max_value, 0, 1) +end + +local function percent_to_ass_alpha(value) + value = clamp(value, 0, 255) + + local p = math.floor(value + 0.5) + + return string.format("&H%02X&", p) +end + +local function scale_value(value, scale) + return value * scale +end + +-- Build the label string, optionally with a leading icon +local function make_label(icon, text, ratio) + local icon_str = "" + if user_opts.show_icons_label and icon and icon ~= "" then + icon_str = icon .. " " + end + + if ratio == -1 then + return string.format("%s%s", icon_str, text) + else + return string.format("%s%s: %d%%", icon_str, text, math.floor(ratio * 100 + 0.5)) + end +end + + +-- Converts colors in HEX formatting to ASS/SSA formatting +local function osd_color_convert(color) + return "&H" .. color:sub(6,7) .. color:sub(4,5) .. color:sub(2,3) .. "&" +end + +local function validate_colors() + for key, value in pairs(user_opts) do + if type(value) == "string" and value:sub(1,1) == "#" then + if value:find("^#%x%x%x%x%x%x$") == nil then + msg.warn("'" .. value .. "' is not a valid color") + end + + -- if invalid, color will fallback to black, same as OSC behaviour + user_opts[key] = osd_color_convert(value) + end + end +end + + + +-- ----------------------------------------------- +-- OSD Helpers +-- ----------------------------------------------- + +local function clear_osd() + local w, h = mp.get_osd_size() + mp.set_osd_ass(w, h, "") +end + +local function schedule_clear() + if state.hide_timer then + state.hide_timer:kill() + end + + state.hide_timer = mp.add_timeout(user_opts.duration, clear_osd) +end + +-- ----------------------------------------------- +-- ASS Style Helpers +-- ----------------------------------------------- + +-- Primary color tag +local function ass_color(color) + return string.format("{\\c%s}", color) +end + +-- Alpha tag +local function ass_alpha(alpha) + return string.format("{\\1a%s}", percent_to_ass_alpha(alpha)) +end + +-- Font size tag +local function ass_fs(size) + return string.format("{\\fs%d}", size) +end + +local function ass_border(size) + return string.format("{\\bord%d}", size) +end + +local function ass_shadow(size) + return string.format("{\\shad%d}", size) +end + +local function ass_border_color(color) + return string.format("{\\3c%s}", color) +end + +local function ass_border_alpha(alpha) + return string.format("{\\3a%s}", percent_to_ass_alpha(alpha)) +end + +local function ass_shadow_color(color) + return string.format("{\\4c%s}", color) +end + +local function remove_border() + return "{\\bord0}" +end + +local function remove_shadow() + return "{\\shad0}" +end + +local function position_center() + return "{\\an5}" +end + +local function position_top_left() + return "{\\an7}" +end + +-- Optional font name tag +local function ass_font_tag() + if user_opts.font and user_opts.font ~= "" then + return string.format("{\\fn%s}", user_opts.font) + end + return "" +end + +-- Reset all overrides +local function ass_reset() + return "{\\r}" +end + +-- ----------------------------------------------- +-- Drawing Functions +-- ----------------------------------------------- + +local function draw_text_center(ass, s_diagonal, s_width, s_height, text, approx_w, approx_h) + + local s_box_pad_x = scale_value(user_opts.label_box_pad_x, s_width) + local s_box_pad_y = scale_value(user_opts.label_box_pad_y, s_height) + + -- background box + if user_opts.text_box then + ass:new_event() + ass:pos(s_width / 2, s_height / 2) + ass:append(ass_reset()) + ass:append(ass_color(user_opts.text_box_color)) + ass:append(ass_alpha(user_opts.text_box_alpha)) + ass:append(ass_border_color(user_opts.text_box_border_color)) + ass:append(ass_border_alpha(user_opts.text_box_border_alpha)) + ass:append(remove_shadow()) + + ass:draw_start() + ass:rect_cw(-approx_w/2 - s_box_pad_x, -approx_h/2 - s_box_pad_y, approx_w/2 + s_box_pad_x, approx_h/2 + s_box_pad_y) + ass:draw_stop() + end + + -- text + ass:new_event() + ass:pos(s_width / 2, s_height / 2) + ass:append(ass_reset()) + ass:append(ass_font_tag()) + ass:append(ass_fs(scale_value(user_opts.font_size, s_diagonal))) + ass:append(ass_color(user_opts.text_color)) + ass:append(ass_alpha(user_opts.text_alpha)) + ass:append(position_center()) + ass:append(text) + +end + +local function draw_text(ass, s_diagonal, s_width, s_height, lx, ly, text, approx_w, approx_h) + + local s_box_pad_x = scale_value(user_opts.label_box_pad_x, s_width) + local s_box_pad_y = scale_value(user_opts.label_box_pad_y, s_height) + + -- background box + if user_opts.text_box then + ass:new_event() + ass:pos(0, 0) + ass:append(ass_reset()) + ass:append(ass_color(user_opts.text_box_color)) + ass:append(ass_alpha(user_opts.text_box_alpha)) + ass:append(ass_border_color(user_opts.text_box_border_color)) + ass:append(ass_border_alpha(user_opts.text_box_border_alpha)) + ass:append(remove_shadow()) + ass:draw_start() + ass:rect_cw(lx - s_box_pad_x, ly - s_box_pad_y, lx + approx_w + s_box_pad_x, ly + approx_h + s_box_pad_y) + ass:draw_stop() + end + + -- text + ass:new_event() + ass:pos(lx, ly) + ass:append(ass_reset()) + ass:append(position_top_left()) + ass:append(ass_font_tag()) + ass:append(ass_fs(scale_value(user_opts.font_size, s_diagonal))) + ass:append(ass_color(user_opts.text_color)) + ass:append(ass_alpha(user_opts.text_alpha)) + ass:append(ass_border(scale_value(user_opts.text_border, s_diagonal))) + ass:append(ass_border_color(user_opts.text_border_color)) + ass:append(ass_shadow(scale_value(user_opts.text_shadow, s_diagonal))) + ass:append(ass_shadow_color(user_opts.text_shadow_color)) + ass:append(ass_border_color(user_opts.text_border_color)) + ass:append(ass_border_alpha(user_opts.text_border_alpha)) + ass:append(text) +end + +local function draw_label(ass, s_diagonal, s_width, s_height, lx, ly, icon, label, ratio) + + local text = make_label(icon, label, ratio) + + -- approximate text size + local approx_h = scale_value(user_opts.font_size, s_diagonal) + local approx_w = #text * approx_h * user_opts.label_box_size + + if user_opts.text_center then + draw_text_center(ass, s_diagonal, s_width, s_height, text, approx_w, approx_h) + else + draw_text(ass, s_diagonal, s_width, s_height, lx, ly, text, approx_w, approx_h) + end + +end + +local function draw_icon(ass, s_diagonal, s_width, s_height, x, y, icon) + if not (user_opts.show_icons_bar and icon and icon ~= "") then return end + + local v_icon_size = scale_value(user_opts.icon_size, s_diagonal) + + ass:new_event() + ass:pos(x - scale_value(user_opts.distance_bar, s_width) - v_icon_size / 2, y + scale_value(user_opts.bar_height, s_height) / 2 - v_icon_size / 2 + scale_value(user_opts.icon_y_offset, s_height)) + ass:append(ass_reset()) + ass:append(position_top_left()) + ass:append(ass_fs(v_icon_size)) + ass:append(ass_color(user_opts.icon_color)) + ass:append(ass_alpha(user_opts.icon_alpha)) + ass:append(ass_border_color(user_opts.icon_border_color)) + ass:append(ass_border_alpha(user_opts.icon_border_alpha)) + ass:append(icon) +end + +local function draw_outline(ass, s_diagonal, s_width, s_height, x, y) + if not user_opts.show_bar_outline then return end + + ass:new_event() + ass:pos(x, y) + ass:append(ass_reset()) + ass:append(ass_color(user_opts.bar_outline_color)) + ass:append(ass_alpha(user_opts.bar_outline_alpha)) + ass:append(ass_border_color(user_opts.bar_outline_border_color)) + ass:append(ass_border_alpha(user_opts.bar_outline_border_alpha)) + + local v_bar_width = scale_value(user_opts.bar_width, s_width) + local v_bar_height = scale_value(user_opts.bar_height, s_height) + + local g = scale_value(user_opts.outline_gap, s_diagonal) + local r = clamp(scale_value(user_opts.bar_radius, v_bar_height), 0, v_bar_height / 2) + local x0 = -g + local y0 = -g + local x1 = v_bar_width + g + local y1 = v_bar_height + g + + ass:draw_start() + + -- outer path + if r > 0 and user_opts.round_fill_end then + ass:move_to(x0 + r, y0) + ass:line_to(x1 - r, y0) + ass:line_to(x1, y0 + r) + ass:line_to(x1, y1 - r) + ass:line_to(x1 - r, y1) + ass:line_to(x0 + r, y1) + ass:line_to(x0, y1 - r) + ass:line_to(x0, y0 + r) + elseif r > 0 then + ass:move_to(x0 + r, y0) + ass:line_to(x1, y0) + ass:line_to(x1, y1) + ass:line_to(x0 + r, y1) + ass:line_to(x0, y1 - r) + ass:line_to(x0, y0 + r) + else + ass:move_to(x0, y0) + ass:line_to(x1, y0) + ass:line_to(x1, y1) + ass:line_to(x0, y1) + end + + -- inner hole + if r > 0 and user_opts.round_fill_end then + ass:move_to(r, 0) + ass:line_to(0, r) + ass:line_to(0, v_bar_height - r) + ass:line_to(r, v_bar_height) + ass:line_to(v_bar_width - r, v_bar_height) + ass:line_to(v_bar_width, v_bar_height - r) + ass:line_to(v_bar_width, r) + ass:line_to(v_bar_width - r, 0) + elseif r > 0 then + ass:move_to(r, 0) + ass:line_to(0, r) + ass:line_to(0, v_bar_height - r) + ass:line_to(r, v_bar_height) + ass:line_to(v_bar_width, v_bar_height) + ass:line_to(v_bar_width, 0) + else + ass:move_to(0, 0) + ass:line_to(0, v_bar_height) + ass:line_to(v_bar_width, v_bar_height) + ass:line_to(v_bar_width, 0) + end + + ass:draw_stop() +end + +local function draw_bar_body(ass, s_diagonal, s_width, s_height, x, y) + + local v_bar_height = scale_value(user_opts.bar_height, s_height) + + ass:new_event() + ass:pos(x, y) + ass:append(ass_reset()) + ass:append(ass_color(user_opts.bar_body_color)) + ass:append(ass_alpha(user_opts.bar_body_alpha)) + ass:append(ass_border_color(user_opts.bar_body_border_color)) + ass:append(ass_border_alpha(user_opts.bar_body_border_alpha)) + + ass:draw_start() + + local r = clamp(scale_value(user_opts.bar_radius, v_bar_height), 0, v_bar_height / 2) + + if r > 0 then + local bw, bh = scale_value(user_opts.bar_width, s_width), v_bar_height + ass:move_to(r, 0) + ass:line_to(bw - r, 0) + ass:line_to(bw, r) + ass:line_to(bw, bh - r) + ass:line_to(bw - r, bh) + ass:line_to(r, bh) + ass:line_to(0, bh - r) + ass:line_to(0, r) + else + ass:rect_cw(0, 0, scale_value(user_opts.bar_width, s_width), v_bar_height) + end + + ass:draw_stop() +end + +local function draw_fill(ass, s_diagonal, s_width, s_height, x, y, filled) + if filled <= 0 then return end + + local v_bar_height = scale_value(user_opts.bar_height, s_height) + + ass:new_event() + ass:pos(x, y) + ass:append(ass_reset()) + ass:append(ass_color(user_opts.bar_fill_color)) + ass:append(ass_alpha(user_opts.bar_fill_alpha)) + ass:append(ass_border_color(user_opts.bar_fill_border_color)) + ass:append(ass_border_alpha(user_opts.bar_fill_border_alpha)) + + ass:draw_start() + + local r = clamp(scale_value(user_opts.bar_radius, v_bar_height), 0, v_bar_height / 2) + + if r > 0 then + local bh = v_bar_height + if user_opts.round_fill_end then + -- rounded on both left and right ends + ass:move_to(r, 0) + ass:line_to(filled - r, 0) + ass:line_to(filled, r) + ass:line_to(filled, bh - r) + ass:line_to(filled - r, bh) + ass:line_to(r, bh) + ass:line_to(0, bh - r) + ass:line_to(0, r) + else + -- rounded only on left, flat on right + ass:move_to(r, 0) + ass:line_to(filled, 0) + ass:line_to(filled, bh) + ass:line_to(r, bh) + ass:line_to(0, bh - r) + ass:line_to(0, r) + end + else + ass:rect_cw(0, 0, filled, v_bar_height) + end + + ass:draw_stop() +end + +local function draw_marker(ass, s_diagonal, s_width, s_height, x, y, marker_position) + + local v_bar_height = scale_value(user_opts.bar_height, s_height) + + local bord = scale_value(user_opts.text_border, s_diagonal) + local mark_x = x + (scale_value(user_opts.bar_width, s_width) * marker_position) + bord / 2 + local half_base = scale_value(user_opts.bar_width, s_width) * user_opts.marker_width + local mark_height = v_bar_height * user_opts.marker_height + + local bar_top = y + local bar_bottom = y + v_bar_height + + local g = scale_value(user_opts.outline_gap, s_diagonal) + + -- triangle to form V shape + ass:new_event() + ass:pos(0, 0) + ass:append(ass_reset()) + ass:append(ass_color(user_opts.bar_body_border_color)) + ass:append(ass_alpha(user_opts.bar_body_border_alpha)) + ass:append(remove_border()) + ass:append(remove_shadow()) + ass:draw_start() + ass:move_to(mark_x - half_base - bord, bar_top) + ass:line_to(mark_x + half_base + bord, bar_top) + ass:line_to(mark_x, bar_top + mark_height + bord) + ass:move_to(mark_x - half_base - bord, bar_bottom) + ass:line_to(mark_x + half_base + bord, bar_bottom) + ass:line_to(mark_x, bar_bottom - mark_height - bord) + ass:draw_stop() + + -- triangle that hides the inside of the other triangle + ass:new_event() + ass:pos(0, 0) + ass:append(ass_reset()) + ass:append(ass_color(user_opts.bar_outline_color)) + ass:append(ass_alpha(user_opts.bar_outline_alpha)) + ass:append(remove_border()) + ass:append(remove_shadow()) + ass:draw_start() + ass:move_to(mark_x - half_base, bar_top - g / 2) + ass:line_to(mark_x + half_base, bar_top - g / 2) + ass:line_to(mark_x, bar_top + mark_height) + ass:move_to(mark_x - half_base, bar_bottom + g / 2) + ass:line_to(mark_x + half_base, bar_bottom + g / 2) + ass:line_to(mark_x, bar_bottom - mark_height) + ass:draw_stop() +end + +local function draw_thin_marker(ass, s_diagonal, s_width, s_height, x, y, marker_position) + + local v_bar_height = scale_value(user_opts.bar_height, s_height) + + local bord = scale_value(user_opts.text_border, s_diagonal) + local mark_x = x + (scale_value(user_opts.bar_width, s_width) * marker_position) + bord / 2 + local mark_height = v_bar_height * user_opts.marker_height + local half_base = scale_value(user_opts.bar_width, s_width) * user_opts.marker_width + + local bar_top = y + local bar_bottom = y + v_bar_height + + ass:new_event() + ass:pos(0, 0) + ass:append(ass_reset()) + ass:append(ass_color(user_opts.bar_outline_color)) + ass:append(ass_alpha(user_opts.bar_outline_alpha)) + ass:append(ass_border_color(user_opts.bar_outline_color)) + ass:append(remove_border()) + ass:append(remove_shadow()) + ass:draw_start() + + ass:move_to(mark_x - half_base, bar_top) + ass:line_to(mark_x + half_base, bar_top) + ass:line_to(mark_x, bar_top + mark_height) + + ass:move_to(mark_x - half_base, bar_bottom) + ass:line_to(mark_x + half_base, bar_bottom) + ass:line_to(mark_x, bar_bottom - mark_height) + + ass:draw_stop() +end + +local function draw_bar(icon, label, value, min_value, max_value, marker_position) + local s_width, s_height = mp.get_osd_size() + local s_diagonal = math.sqrt(s_width * s_width + s_height * s_height) + local is_color_controls = (label == "Brightness" or label == "Saturation" or label == "Contrast") + + local ratio = 0 + if is_color_controls then + ratio = to_ratio(value + 100, math.abs(min_value) + max_value) + else + ratio = to_ratio(value, math.abs(min_value) + max_value) + end + + local filled = scale_value(user_opts.bar_width, s_width) * ratio + + local label_ratio = (label == "Volume" or is_color_controls) and (value / 100) or ratio + + local x = (s_width - scale_value(user_opts.bar_width, s_width)) / 2 + scale_value(user_opts.bar_x_offset, s_width) + local y = s_height * user_opts.bar_y_pos + local lx = scale_value(user_opts.label_x_offset, s_width) + local ly = scale_value(user_opts.label_y_offset, s_height) + + local ass = assdraw.ass_new() + + if label == "Paused" or label == "Playing" then + draw_label(ass, s_diagonal, s_width, s_height, lx, ly, icon, label, -1) + if not user_opts.bar_when_pausing then + mp.set_osd_ass(s_width, s_height, ass.text) + schedule_clear() + return + end + else + draw_label(ass, s_diagonal, s_width, s_height, lx, ly, icon, label, label_ratio) + end + + draw_icon(ass, s_diagonal, s_width, s_height, x, y, icon) + draw_outline(ass, s_diagonal, s_width, s_height, x, y) + draw_bar_body(ass, s_diagonal, s_width, s_height, x, y) + draw_fill(ass, s_diagonal, s_width, s_height, x, y, filled) + + if marker_position then + if user_opts.show_bar_outline then + draw_marker(ass, s_diagonal, s_width, s_height, x, y, marker_position) + else + draw_thin_marker(ass, s_diagonal, s_width, s_height, x, y, marker_position) + end + end + + mp.set_osd_ass(s_width, s_height, ass.text) + schedule_clear() +end + +-- ----------------------------------------------- +-- OSD Actions +-- ----------------------------------------------- + +local function show_volume() + if not user_opts.show_volume then return end + local volume = mp.get_property_number("volume", 0) + local muted = mp.get_property_bool("mute", false) + local icon = (muted or volume == 0) and user_opts.icon_mute or user_opts.icon_volume + draw_bar(icon, "Volume", volume, 0, 130, 100 / 130) +end + +local function show_seek() + if not user_opts.show_seek then return end + local time_pos = mp.get_property_number("time-pos", 0) + local duration = mp.get_property_number("duration", 0) + draw_bar(user_opts.icon_seek, "Seek", time_pos, 0, duration, nil) +end + +local function show_pause_state() + if not user_opts.show_pause then return end + local paused = mp.get_property_bool("pause", false) + local time_pos = mp.get_property_number("time-pos", 0) + local duration = mp.get_property_number("duration", 0) + + local icon = (paused) and user_opts.icon_pause or user_opts.icon_play + local label = (paused) and "Paused" or "Playing" + + draw_bar(icon, label, time_pos, 0, duration, nil) +end + +local function show_brightness() + if not user_opts.show_brightness then return end + local v = mp.get_property_number("brightness", 0) + draw_bar(user_opts.icon_brightness, "Brightness", v, -100, 100, 0.5) +end + +local function show_saturation() + if not user_opts.show_saturation then return end + local v = mp.get_property_number("saturation", 0) + draw_bar(user_opts.icon_saturation, "Saturation", v, -100, 100, 0.5) +end + +local function show_contrast() + if not user_opts.show_contrast then return end + local v = mp.get_property_number("contrast", 0) + draw_bar(user_opts.icon_contrast, "Contrast", v, -100, 100, 0.5) +end + + + +-- Disable C OSD +mp.set_property("osd-level", "0") + + +-- Read options from config file and command-line with callback possibility +opt.read_options(user_opts, "osd", function(changed) + validate_colors() +end) + +validate_colors() + + + + +-- ----------------------------------------------- +-- Hook to mpv +-- ----------------------------------------------- + +mp.observe_property("volume", "number", show_volume) +mp.observe_property("mute", "bool", show_volume) +mp.observe_property("pause", "bool", show_pause_state) +mp.observe_property("brightness", "number", show_brightness) +mp.observe_property("saturation", "number", show_saturation) +mp.observe_property("contrast", "number", show_contrast) + +mp.register_event("seek", show_seek) + +msg.info("Lua OSD loaded") + + From 1e143329b08fb820c28a3ff3296375d884abfdbc Mon Sep 17 00:00:00 2001 From: Alexandre Delgado Date: Fri, 29 May 2026 15:22:54 +0100 Subject: [PATCH 3/4] player: integrate the Lua script into the code Minor changes so that mpv's code recognizes and loads the Lua script for the OSD. This script is only loaded and run if the --osd-lua option is provided with "yes". Signed-off-by: Alexandre Delgado Co-authored-by: Ricardo Fonseca --- player/core.h | 2 +- player/lua.c | 3 +++ player/lua/meson.build | 2 +- player/scripting.c | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/player/core.h b/player/core.h index b5e2f09ac3e01..7fdfd1e50b52f 100644 --- a/player/core.h +++ b/player/core.h @@ -452,7 +452,7 @@ typedef struct MPContext { struct mp_ipc_ctx *ipc_ctx; - int64_t builtin_script_ids[9]; + int64_t builtin_script_ids[10]; mp_mutex abort_lock; diff --git a/player/lua.c b/player/lua.c index 8ecdeac410fe5..5c64d6c90d8b1 100644 --- a/player/lua.c +++ b/player/lua.c @@ -67,6 +67,9 @@ static const char * const builtin_lua_scripts[][2] = { }, {"mp.options", # include "player/lua/options.lua.inc" + }, + {"@osd.lua", +# include "player/lua/osd.lua.inc" }, {"@osc.lua", # include "player/lua/osc.lua.inc" diff --git a/player/lua/meson.build b/player/lua/meson.build index 72c92261fbaef..e9746321910b7 100644 --- a/player/lua/meson.build +++ b/player/lua/meson.build @@ -1,4 +1,4 @@ -lua_files = ['defaults.lua', 'assdraw.lua', 'options.lua', 'osc.lua', +lua_files = ['defaults.lua', 'assdraw.lua', 'options.lua', 'osd.lua', 'osc.lua', 'ytdl_hook.lua', 'stats.lua', 'console.lua', 'auto_profiles.lua', 'input.lua', 'fzy.lua', 'select.lua', 'positioning.lua', 'commands.lua', 'context_menu.lua'] diff --git a/player/scripting.c b/player/scripting.c index 6ed582f14265a..edba6a5eee433 100644 --- a/player/scripting.c +++ b/player/scripting.c @@ -271,6 +271,7 @@ void mp_load_builtin_scripts(struct MPContext *mpctx) load_builtin_script(mpctx, 6, mpctx->opts->lua_load_positioning, "@positioning.lua"); load_builtin_script(mpctx, 7, mpctx->opts->lua_load_commands, "@commands.lua"); load_builtin_script(mpctx, 8, mpctx->opts->lua_load_context_menu, "@context_menu.lua"); + load_builtin_script(mpctx, 9, mpctx->opts->lua_load_osd, "@osd.lua"); } bool mp_load_scripts(struct MPContext *mpctx) From c17c4cda490e4d551c3fb53c608785627b3f7101 Mon Sep 17 00:00:00 2001 From: Alexandre Delgado Date: Fri, 29 May 2026 15:23:25 +0100 Subject: [PATCH 4/4] test: add tests for the Lua OSD Created two tests to ensure proper behaviour of the Lua OSD. One test ensures that mpv is able to load a video file with the Lua OSD running and the other simulates both OSDs and compares values like volume or paused state to ensure no value of the video is being changed by the Lua OSD. Closes #16991 Signed-off-by: Alexandre Delgado Co-authored-by: Ricardo Fonseca --- test/libmpv_test_file_loading_lua_osd.c | 77 ++++++ test/libmpv_test_lua_osd.c | 343 ++++++++++++++++++++++++ test/meson.build | 8 + 3 files changed, 428 insertions(+) create mode 100644 test/libmpv_test_file_loading_lua_osd.c create mode 100644 test/libmpv_test_lua_osd.c diff --git a/test/libmpv_test_file_loading_lua_osd.c b/test/libmpv_test_file_loading_lua_osd.c new file mode 100644 index 0000000000000..b53d4ec59e787 --- /dev/null +++ b/test/libmpv_test_file_loading_lua_osd.c @@ -0,0 +1,77 @@ +/* + * This file is part of mpv. + * + * mpv is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * mpv is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with mpv. If not, see . + */ + +#include "libmpv_common.h" + +static void test_file_loading(char *file) +{ + const char *cmd[] = {"loadfile", file, NULL}; + command(cmd); + bool loaded = false; + bool finished = false; + bool c_osd_suppressed = false; + while (!finished) { + mpv_event *event = wrap_wait_event(); + switch (event->event_id) { + case MPV_EVENT_FILE_LOADED: + // make sure it loads before exiting + loaded = true; + + // make sure we're not using the C OSD and only the Lua one + if (!strcmp(mpv_get_property_string(ctx, "osd-level"), "0")) { + c_osd_suppressed = true; + } + + break; + case MPV_EVENT_END_FILE: + if (loaded) + finished = true; + break; + } + } + + if(!c_osd_suppressed) + fail("Failed to suppress the C OSD"); + + if (!finished) + fail("Unable to load test file!\n"); +} + +int main(int argc, char *argv[]) +{ + if (argc != 2) + return 1; + + ctx = mpv_create(); + if (!ctx) + return 1; + + atexit(exit_cleanup); + + mpv_set_option_string(ctx, "osd-lua", "yes"); + initialize(); + + const char *fmt = "================ TEST: %s ================\n"; + printf(fmt, "test_file_loading for Lua OSD"); + test_file_loading(argv[1]); + printf("================ SHUTDOWN ================\n"); + + command_string("quit"); + while (wrap_wait_event()->event_id != MPV_EVENT_SHUTDOWN) {} + + return 0; +} diff --git a/test/libmpv_test_lua_osd.c b/test/libmpv_test_lua_osd.c new file mode 100644 index 0000000000000..81c0fae0d6d97 --- /dev/null +++ b/test/libmpv_test_lua_osd.c @@ -0,0 +1,343 @@ +/* + * This file is part of mpv. + * + * mpv is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * mpv is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with mpv. If not, see . + */ + +#include "libmpv_common.h" + + +struct osdTestValues { + char* initial_volume; + char* initial_mute; + char* started_paused; + char* initial_brightness; + char* initial_saturation; + char* initial_contrast; + + char* volume_after_change; + char* second_mute; + char* second_paused; + char* brightness_after_change; + char* saturation_after_change; + char* contrast_after_change; +}; + + +static void set_option_string(const char *name, const char *value) +{ + int ret = mpv_set_option_string(ctx, name, value); + if (ret < 0) + fail("mpv API error while setting option '%s' to '%s' (%s)\n", name, value, mpv_error_string(ret)); +} + + +static void free_osd_values(struct osdTestValues* C_osd_values, struct osdTestValues* Lua_osd_values) { + // Free everything + mpv_free(C_osd_values->initial_volume); + mpv_free(C_osd_values->initial_mute); + mpv_free(C_osd_values->started_paused); + mpv_free(C_osd_values->initial_brightness); + mpv_free(C_osd_values->initial_saturation); + mpv_free(C_osd_values->initial_contrast); + + mpv_free(C_osd_values->volume_after_change); + mpv_free(C_osd_values->second_mute); + mpv_free(C_osd_values->second_paused); + mpv_free(C_osd_values->brightness_after_change); + mpv_free(C_osd_values->saturation_after_change); + mpv_free(C_osd_values->contrast_after_change); + + mpv_free(Lua_osd_values->initial_volume); + mpv_free(Lua_osd_values->initial_mute); + mpv_free(Lua_osd_values->started_paused); + mpv_free(Lua_osd_values->initial_brightness); + mpv_free(Lua_osd_values->initial_saturation); + mpv_free(Lua_osd_values->initial_contrast); + + mpv_free(Lua_osd_values->volume_after_change); + mpv_free(Lua_osd_values->second_mute); + mpv_free(Lua_osd_values->second_paused); + mpv_free(Lua_osd_values->brightness_after_change); + mpv_free(Lua_osd_values->saturation_after_change); + mpv_free(Lua_osd_values->contrast_after_change); +} + + +static void test_c_osd(struct osdTestValues* C_osd_values) +{ + + C_osd_values->initial_volume = mpv_get_property_string(ctx, "volume"); + printf("Initial volume: %s\n", C_osd_values->initial_volume); + + C_osd_values->initial_mute = mpv_get_property_string(ctx, "mute"); + printf("Initial mute flag: %s\n", C_osd_values->initial_mute); + + C_osd_values->started_paused = mpv_get_property_string(ctx, "pause"); + printf("Started paused: %s\n", C_osd_values->started_paused); + + C_osd_values->initial_brightness = mpv_get_property_string(ctx, "brightness"); + printf("Initial brightness: %s\n", C_osd_values->initial_brightness); + + C_osd_values->initial_saturation = mpv_get_property_string(ctx, "saturation"); + printf("Initial saturation: %s\n", C_osd_values->initial_saturation); + + C_osd_values->initial_contrast = mpv_get_property_string(ctx, "contrast"); + printf("Initial contrast: %s\n", C_osd_values->initial_contrast); + + + + mpv_set_property_string(ctx, "volume", "60"); + C_osd_values->volume_after_change = mpv_get_property_string(ctx, "volume"); + printf("Volume after change: %s\n", C_osd_values->volume_after_change); + + mpv_set_property_string(ctx, "mute", "yes"); + C_osd_values->second_mute = mpv_get_property_string(ctx, "mute"); + printf("Mute flag after changing it: %s\n", C_osd_values->second_mute); + + mpv_set_property_string(ctx, "pause", "yes"); + C_osd_values->second_paused = mpv_get_property_string(ctx, "pause"); + printf("Paused after pause command: %s\n", C_osd_values->second_paused); + + mpv_set_property_string(ctx, "brightness", "-70"); + C_osd_values->brightness_after_change = mpv_get_property_string(ctx, "brightness"); + printf("Brightness after change: %s\n", C_osd_values->brightness_after_change); + + mpv_set_property_string(ctx, "saturation", "50"); + C_osd_values->saturation_after_change = mpv_get_property_string(ctx, "saturation"); + printf("Saturation after change: %s\n", C_osd_values->saturation_after_change); + + mpv_set_property_string(ctx, "contrast", "4"); + C_osd_values->contrast_after_change = mpv_get_property_string(ctx, "contrast"); + printf("Contrast after change: %s\n", C_osd_values->contrast_after_change); + + + +} + + + +static void test_lua_osd(struct osdTestValues* Lua_osd_values) +{ + + Lua_osd_values->initial_volume = mpv_get_property_string(ctx, "volume"); + printf("Initial volume: %s\n", Lua_osd_values->initial_volume); + + Lua_osd_values->initial_mute = mpv_get_property_string(ctx, "mute"); + printf("Initial mute flag: %s\n", Lua_osd_values->initial_mute); + + Lua_osd_values->started_paused = mpv_get_property_string(ctx, "pause"); + printf("Started paused: %s\n", Lua_osd_values->started_paused); + + Lua_osd_values->initial_brightness = mpv_get_property_string(ctx, "brightness"); + printf("Initial brightness: %s\n", Lua_osd_values->initial_brightness); + + Lua_osd_values->initial_saturation = mpv_get_property_string(ctx, "saturation"); + printf("Initial saturation: %s\n", Lua_osd_values->initial_saturation); + + Lua_osd_values->initial_contrast = mpv_get_property_string(ctx, "contrast"); + printf("Initial contrast: %s\n", Lua_osd_values->initial_contrast); + + + mpv_set_property_string(ctx, "volume", "60"); + Lua_osd_values->volume_after_change = mpv_get_property_string(ctx, "volume"); + printf("Volume after change: %s\n", Lua_osd_values->volume_after_change); + + mpv_set_property_string(ctx, "mute", "yes"); + Lua_osd_values->second_mute = mpv_get_property_string(ctx, "mute"); + printf("Mute flag after changing it: %s\n", Lua_osd_values->second_mute); + + mpv_set_property_string(ctx, "pause", "yes"); + Lua_osd_values->second_paused = mpv_get_property_string(ctx, "pause"); + printf("Paused after pause command: %s\n", Lua_osd_values->second_paused); + + mpv_set_property_string(ctx, "brightness", "-70"); + Lua_osd_values->brightness_after_change = mpv_get_property_string(ctx, "brightness"); + printf("Brightness after change: %s\n", Lua_osd_values->brightness_after_change); + + mpv_set_property_string(ctx, "saturation", "50"); + Lua_osd_values->saturation_after_change = mpv_get_property_string(ctx, "saturation"); + printf("Saturation after change: %s\n", Lua_osd_values->saturation_after_change); + + mpv_set_property_string(ctx, "contrast", "4"); + Lua_osd_values->contrast_after_change = mpv_get_property_string(ctx, "contrast"); + printf("Luaontrast after change: %s\n", Lua_osd_values->contrast_after_change); + + + + +} + +static void test_compare_values(struct osdTestValues* C_osd_values, struct osdTestValues* Lua_osd_values) +{ + + int number_of_matches = 0; + const char *matched = "%s: Matched.\n"; + const char *no_matched = "%s: DID NOT MATCH (C OSD = %s; Lua OSD = %s)\n"; + + if (strcmp(C_osd_values->initial_volume, Lua_osd_values->initial_volume)) { + printf(no_matched, "Initial volume", C_osd_values->initial_volume, Lua_osd_values->initial_volume); + } else { + printf(matched, "Initial volume"); + number_of_matches++; + } + + if (strcmp(C_osd_values->initial_mute, Lua_osd_values->initial_mute)) { + printf(no_matched, "Initial mute flag", C_osd_values->initial_mute, Lua_osd_values->initial_mute); + } else { + printf(matched, "Initial mute flag"); + number_of_matches++; + } + + if (strcmp(C_osd_values->started_paused, Lua_osd_values->started_paused)) { + printf(no_matched, "Started paused", C_osd_values->started_paused, Lua_osd_values->started_paused); + } else { + printf(matched, "Started paused"); + number_of_matches++; + } + + if (strcmp(C_osd_values->initial_brightness, Lua_osd_values->initial_brightness)) { + printf(no_matched, "Initial brightness", C_osd_values->initial_brightness, Lua_osd_values->initial_brightness); + } else { + printf(matched, "Initial brightness"); + number_of_matches++; + } + + if (strcmp(C_osd_values->initial_saturation, Lua_osd_values->initial_saturation)) { + printf(no_matched, "Initial saturation", C_osd_values->initial_saturation, Lua_osd_values->initial_saturation); + } else { + printf(matched, "Initial saturation"); + number_of_matches++; + } + + if (strcmp(C_osd_values->initial_contrast, Lua_osd_values->initial_contrast)) { + printf(no_matched, "Initial contrast", C_osd_values->initial_contrast, Lua_osd_values->initial_contrast); + } else { + printf(matched, "Initial contrast"); + number_of_matches++; + } + + + + if (strcmp(C_osd_values->volume_after_change, Lua_osd_values->volume_after_change)) { + printf(no_matched, "Volume after change", C_osd_values->volume_after_change, Lua_osd_values->volume_after_change); + } else { + printf(matched, "Volume after change"); + number_of_matches++; + } + + + if (strcmp(C_osd_values->second_mute, Lua_osd_values->second_mute)) { + printf(no_matched, "Mute flag after changing it", C_osd_values->second_mute, Lua_osd_values->second_mute); + } else { + printf(matched, "Mute flag after changing it"); + number_of_matches++; + } + + + if (strcmp(C_osd_values->second_paused, Lua_osd_values->second_paused)) { + printf(no_matched, "Paused after pause command", C_osd_values->second_paused, Lua_osd_values->second_paused); + } else { + printf(matched, "Paused after pause command"); + number_of_matches++; + } + + if (strcmp(C_osd_values->brightness_after_change, Lua_osd_values->brightness_after_change)) { + printf(no_matched, "Brightness after change", C_osd_values->brightness_after_change, Lua_osd_values->brightness_after_change); + } else { + printf(matched, "Brightness after change"); + number_of_matches++; + } + + if (strcmp(C_osd_values->saturation_after_change, Lua_osd_values->saturation_after_change)) { + printf(no_matched, "Saturation after change", C_osd_values->saturation_after_change, Lua_osd_values->saturation_after_change); + } else { + printf(matched, "Saturation after change"); + number_of_matches++; + } + + if (strcmp(C_osd_values->contrast_after_change, Lua_osd_values->contrast_after_change)) { + printf(no_matched, "Contrast after change", C_osd_values->contrast_after_change, Lua_osd_values->contrast_after_change); + } else { + printf(matched, "Contrast after change"); + number_of_matches++; + } + + + fflush(stdout); // to ensure correct order of prints + if (number_of_matches != 12) { + fail("\nSome values of Lua's OSD did not match the intended values from the C OSD.\n"); + } + + + free_osd_values(C_osd_values, Lua_osd_values); +} + + + + +int main(void) +{ + struct osdTestValues C_osd_values; + struct osdTestValues Lua_osd_values; + + + ctx = mpv_create(); + if (!ctx) + return 1; + + atexit(exit_cleanup); + + set_option_string("osd-lua", "no"); + initialize(); + + printf("================ TEST: Lua OSD ================\n"); + + printf("--------------- Running C OSD to register expected values ---------------\n"); + test_c_osd(&C_osd_values); + printf("--------------- Finished running C OSD ---------------\n"); + + command_string("quit"); + + while (wrap_wait_event()->event_id != MPV_EVENT_SHUTDOWN) {} + +// ----------------------------------------------------------------------------------------- + + ctx = mpv_create(); + if (!ctx) + return 1; + + atexit(exit_cleanup); + + set_option_string("osd-lua", "yes"); + initialize(); + + printf("--------------- Running Lua OSD to register values ---------------\n"); + test_lua_osd(&Lua_osd_values); + printf("--------------- Finished running Lua OSD ---------------\n"); + + command_string("quit"); + while (wrap_wait_event()->event_id != MPV_EVENT_SHUTDOWN) {} + + printf("================ SHUTDOWN ================\n"); + +// ----------------------------------------------------------------------------------------- + + printf("--------------- Comparing Lua OSD values with C OSD values ---------------\n"); + test_compare_values(&C_osd_values, &Lua_osd_values); + printf("--------------- Finished comparing values ---------------\n"); + + return 0; +} + diff --git a/test/meson.build b/test/meson.build index 156d2c433b8e4..a87928f2dc7da 100644 --- a/test/meson.build +++ b/test/meson.build @@ -122,6 +122,10 @@ if get_option('libmpv') include_directories: incdir, dependencies: libmpv_dep) test('libmpv-test-file-loading', exe, args: file, suite: 'libmpv') + exe = executable('libmpv-test-file-loading-lua-osd', 'libmpv_test_file_loading_lua_osd.c', + include_directories: incdir, dependencies: libmpv_dep) + test('libmpv-test-file-loading-lua-osd', exe, args: file, suite: 'libmpv') + exe = executable('libmpv-test-lavfi-complex', 'libmpv_test_lavfi_complex.c', include_directories: incdir, dependencies: libmpv_dep) test('libmpv-test-lavfi-complex', exe, args: file, suite: 'libmpv') @@ -130,6 +134,10 @@ if get_option('libmpv') include_directories: incdir, dependencies: libmpv_dep) test('libmpv-test-options', exe, suite: 'libmpv') + exe = executable('libmpv-test-lua-osd', 'libmpv_test_lua_osd.c', + include_directories: incdir, dependencies: libmpv_dep) + test('libmpv-test-lua-osd', exe, suite: 'libmpv') + # Old versions of ffmpeg are bugged when setting forced tracks and older # versions of meson don't support the custom version checking argument. if meson.version().version_compare('>= 1.5.0')