From d36e3686404143ce2fde9b54c866964a93f603b9 Mon Sep 17 00:00:00 2001 From: Guido Cella Date: Thu, 28 Aug 2025 11:25:33 +0200 Subject: [PATCH] context_menu.lua: add this script This implements a ASS context menu to be used on platforms other than Windows. The select script message will allow selecting an item with a single click when releasing a mouse button, like in native context menus. This is mainly useful to cycle pause with one click. --- DOCS/interface-changes/context-menu.rst | 1 + DOCS/man/context_menu.rst | 98 ++++ DOCS/man/mpv.rst | 7 +- DOCS/man/options.rst | 5 + etc/builtin.conf | 2 + options/options.c | 4 + options/options.h | 1 + player/core.h | 2 +- player/lua.c | 3 + player/lua/context_menu.lua | 642 ++++++++++++++++++++++++ player/lua/meson.build | 2 +- player/scripting.c | 1 + 12 files changed, 761 insertions(+), 7 deletions(-) create mode 100644 DOCS/interface-changes/context-menu.rst create mode 100644 DOCS/man/context_menu.rst create mode 100644 player/lua/context_menu.lua diff --git a/DOCS/interface-changes/context-menu.rst b/DOCS/interface-changes/context-menu.rst new file mode 100644 index 0000000000000..ec8043188019a --- /dev/null +++ b/DOCS/interface-changes/context-menu.rst @@ -0,0 +1 @@ +add `--load-context-menu` option diff --git a/DOCS/man/context_menu.rst b/DOCS/man/context_menu.rst new file mode 100644 index 0000000000000..2febf35ef89e4 --- /dev/null +++ b/DOCS/man/context_menu.rst @@ -0,0 +1,98 @@ +CONTEXT MENU SCRIPT +=================== + +This script provides a context menu for platforms without integration with a +native context menu. On these platforms, it can be disabled entirely using the +``--load-context-menu=no`` option. On platforms where the integration is +implemented, it is already disabled by default. + +Script messages +--------------- + +``open`` + Show the context menu. + +``select`` + Select the focused item when there is one. + +Configuration +------------- + +This script can be customized through a config file +``script-opts/context_menu.conf`` placed in mpv's user directory and through +the ``--script-opts`` command-line option. The configuration syntax is +described in `mp.options functions`_. + +Configurable Options +~~~~~~~~~~~~~~~~~~~~ + +``font_size`` + Default: 14 + + The font size. + +``gap`` + Default: 3 + + The gap between menu items. + +``padding_x`` + Default: 8 + + The horizontal padding of the menu. + +``padding_y`` + Default: 4 + + The vertical padding of the menu. + +``menu_outline_size`` + Default: 0 + + The size of the menu's border. + +``menu_outline_color`` + Default: ``#FFFFFF`` + + The color of the menu's border. + +``corner_radius`` + Default: 5 + + The radius of the menu's corners. + +``scale_with_window`` + Default: auto + + Whether to scale sizes with the window height. Can be ``yes``, ``no``, or + ``auto``, which follows the value of ``--osd-scale-by-window``. + + When sizes aren't scaled with the window, they are scaled by + ``display-hidpi-scale``. + +``focused_color`` + Default: ``#222222`` + + The color of the focused item. + +``focused_back_color`` + Default: ``#FFFFFF`` + + The background color of the focused item. + +``disabled_color`` + Default: ``#555555`` + + The color of disabled items. + +``seconds_to_open_submenus`` + Default: 0.2 + + The number of seconds to open submenus after the cursor enters items + associated with one. + +``seconds_to_close_submenus`` + Default: 0.2 + + The number of seconds to close submenus after the cursor enters a parent + menu. diff --git a/DOCS/man/mpv.rst b/DOCS/man/mpv.rst index 6b17eb6158430..895572da2f0f3 100644 --- a/DOCS/man/mpv.rst +++ b/DOCS/man/mpv.rst @@ -402,11 +402,6 @@ Ctrl+Wheel up/down Context Menu ------------- -.. warning:: - - This feature is experimental. It may not work with all VOs. A libass based - fallback may be implemented in the future. - Context Menu is a menu that pops up on the video window on user interaction (mouse right click, etc.). @@ -1548,6 +1543,8 @@ works like in older mpv releases: .. include:: select.rst +.. include:: context_menu.rst + .. include:: positioning.rst .. include:: lua.rst diff --git a/DOCS/man/options.rst b/DOCS/man/options.rst index d718baed623c5..3047c1a4d55b5 100644 --- a/DOCS/man/options.rst +++ b/DOCS/man/options.rst @@ -1066,6 +1066,11 @@ Program Behavior Enable the builtin script that lets you select from lists of items (default: yes). By default, its keybindings start with the ``g`` key. +``--load-context-menu=`` + Enable the builtin script that implements a context menu. Defaults to + ``yes`` on platforms where integration with a native context menu is not + implemented, and to ``no`` on platform where it is. + ``--load-positioning=`` Enable the builtin script that provides various keybindings to pan videos and images (default: yes). diff --git a/etc/builtin.conf b/etc/builtin.conf index cb4d04e68e323..6f19dfae9f2df 100644 --- a/etc/builtin.conf +++ b/etc/builtin.conf @@ -85,6 +85,8 @@ osd-outline-size=0 osd-shadow-offset=4 script-opt=console-border_size=0 script-opt=console-corner_radius=0 +script-opt=context_menu-padding_y=0 +script-opt=context_menu-corner_radius=0 script-opt=stats-border_size=0 [sub-box] diff --git a/options/options.c b/options/options.c index 767283c790ffc..d2fb02b32afd1 100644 --- a/options/options.c +++ b/options/options.c @@ -565,6 +565,7 @@ static const m_option_t mp_opts[] = { {"load-select", OPT_BOOL(lua_load_select), .flags = UPDATE_BUILTIN_SCRIPTS}, {"load-positioning", OPT_BOOL(lua_load_positioning), .flags = UPDATE_BUILTIN_SCRIPTS}, {"load-commands", OPT_BOOL(lua_load_commands), .flags = UPDATE_BUILTIN_SCRIPTS}, + {"load-context-menu", OPT_BOOL(lua_load_context_menu), .flags = UPDATE_BUILTIN_SCRIPTS}, #endif // ------------------------- stream options -------------------- @@ -1000,6 +1001,9 @@ static const struct MPOpts mp_default_opts = { .lua_load_select = true, .lua_load_positioning = true, .lua_load_commands = true, +#ifndef _WIN32 + .lua_load_context_menu = true, +#endif #endif .auto_load_scripts = true, .loop_times = 1, diff --git a/options/options.h b/options/options.h index 6f16f50c25ecf..299bb85a1b6f6 100644 --- a/options/options.h +++ b/options/options.h @@ -183,6 +183,7 @@ typedef struct MPOpts { bool lua_load_select; bool lua_load_positioning; bool lua_load_commands; + bool lua_load_context_menu; bool auto_load_scripts; diff --git a/player/core.h b/player/core.h index f8b68321a9b2a..9498e68ff8b2e 100644 --- a/player/core.h +++ b/player/core.h @@ -444,7 +444,7 @@ typedef struct MPContext { struct mp_ipc_ctx *ipc_ctx; - int64_t builtin_script_ids[8]; + int64_t builtin_script_ids[9]; mp_mutex abort_lock; diff --git a/player/lua.c b/player/lua.c index 478641977cfd1..83964daebbddb 100644 --- a/player/lua.c +++ b/player/lua.c @@ -90,6 +90,9 @@ static const char * const builtin_lua_scripts[][2] = { }, {"@commands.lua", # include "player/lua/commands.lua.inc" + }, + {"@context_menu.lua", +# include "player/lua/context_menu.lua.inc" }, {0} }; diff --git a/player/lua/context_menu.lua b/player/lua/context_menu.lua new file mode 100644 index 0000000000000..38602fb943588 --- /dev/null +++ b/player/lua/context_menu.lua @@ -0,0 +1,642 @@ +--[[ +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 . +]] + +local assdraw = require "mp.assdraw" + +local options = { + font_size = 14, + gap = 3, + padding_x = 8, + padding_y = 4, + menu_outline_size = 0, + menu_outline_color = "#FFFFFF", + corner_radius = 5, + scale_with_window = "auto", + focused_color = "#222222", + focused_back_color = "#FFFFFF", + disabled_color = "#555555", + seconds_to_open_submenus = 0.2, + seconds_to_close_submenus = 0.2, +} + +local open_menus +local items +local focused_level = 1 +local focused_index +local open_submenu_timer +local close_submenu_timer +local overlay = mp.create_osd_overlay("ass-events") +local width_overlay = mp.create_osd_overlay("ass-events") +width_overlay.compute_bounds = true +width_overlay.hidden = true + +local close + +local function escape(str) + return mp.command_native({"escape-ass", str}) +end + +local function update_overlay(data, res_x, res_y, z) + if overlay.data == data and + overlay.res_x == res_x and + overlay.res_y == res_y and + overlay.z == z then + return + end + + overlay.data = data + overlay.res_x = res_x + overlay.res_y = res_y + overlay.z = z + overlay:update() +end + +local function clear_timers() + if open_submenu_timer then + open_submenu_timer:kill() + open_submenu_timer = nil + end + + if close_submenu_timer then + close_submenu_timer:kill() + close_submenu_timer = nil + end +end + +local function should_scale() + return options.scale_with_window == "yes" or + (options.scale_with_window == "auto" and mp.get_property_native("osd-scale-by-window")) +end + +local function scale_factor() + local osd_height = mp.get_property_native("osd-height") + + if should_scale() and osd_height > 0 then + return osd_height / 720 + end + + return mp.get_property_native("display-hidpi-scale", 1) +end + +local function get_scaled_osd_dimensions() + local dims = mp.get_property_native("osd-dimensions") + local scale = scale_factor() + + return dims.w / scale, dims.h / scale +end + +local function get_scaled_mouse_pos() + local pos = mp.get_property_native("mouse-pos") + local scale = scale_factor() + + return pos.x / scale, pos.y / scale, pos.hover +end + +local function has_state(item, state) + for _, value in pairs(item.state or {}) do + if value == state then + return true + end + end +end + +local function has_checkbox(menu_items) + for _, item in pairs(menu_items) do + if has_state(item, "checked") then + return true + end + end +end + +local function get_right_aligned_text(item) + return (item.shortcut or "") .. + (item.shortcut and item.submenu and " " or "") .. + (item.submenu and "▸" or "") +end + +local function calculate_width(menu_items, osd_w, osd_h, checkbox) + local titles = {} + for _, item in pairs(menu_items) do + if item.title then + local right_text = get_right_aligned_text(item) + titles[#titles + 1] = item.title .. + (right_text ~= "" and " " or "") .. right_text + end + end + + local longest = "" + for _, title in pairs(titles) do + if #title > #longest then + longest = title + end + end + + if checkbox then + longest = "✔ " .. longest + end + + for _, item in ipairs(menu_items) do + if has_state(item, "checked") then + longest = "✔ " .. longest + break + end + end + + width_overlay.res_x = osd_w + width_overlay.res_y = osd_h + width_overlay.data = "{\\fs" .. options.font_size .. "\\q2}" .. + escape(longest) + local result = width_overlay:update() + + return math.min(result.x1 - result.x0, osd_w * 0.95) +end + +local function get_line_height() + return options.font_size + options.gap +end + +local function calculate_height(menu_items) + local item_count = 0 + for _, item in pairs(menu_items) do + if item.type ~= "separator" then + item_count = item_count + 1 + end + end + + return item_count * get_line_height() + options.padding_y * 2 +end + +local function add_menu(menu_items, x, y) + if not menu_items[1] then + return + end + + local visible_items = {} + for _, item in ipairs(menu_items) do + if not has_state(item, "hidden") then + visible_items[#visible_items + 1] = item + end + end + + local checkbox = has_checkbox(visible_items) + local osd_w, osd_h = get_scaled_osd_dimensions() + local width = calculate_width(visible_items, osd_w, osd_h, checkbox) + + options.padding_x * 2 + local height = math.min(calculate_height(visible_items), osd_h) + local last_menu = open_menus[#open_menus] + + if x + width > osd_w then + x = math.max(osd_w - width, 0) + + -- If menus overlap + if last_menu and last_menu.x <= x + width + and x <= last_menu.x + last_menu.width then + + x = math.max(last_menu.x - width, 0) + end + end + + if y + height > osd_h then + y = math.max(osd_h - height, 0) + end + + open_menus[#open_menus + 1] = { + items = visible_items, + x = x, + y = y, + width = width, + height = height, + has_checkbox = checkbox, + page = 1, + } +end + +local function mpv_color_to_ass(color) + return color:sub(8,9) .. color:sub(6,7) .. color:sub(4,5) +end + +local function color_option_to_ass(color) + return color:sub(6,7) .. color:sub(4,5) .. color:sub(2,3) +end + +local function append_item(ass, menu, level, style, item, item_y, + non_separator_item_index, line_height) + local focused = (non_separator_item_index == focused_index and level == focused_level) or + (non_separator_item_index == menu.index_with_open_submenu + and (level < focused_level or not focused_index)) + + if focused and not has_state(item, "disabled") then + ass:new_event() + ass:an(4) + ass:pos(menu.x, item_y) + ass:append("{\\blur0\\bord0\\4aH&ff&\\1c&H" .. + color_option_to_ass(options.focused_back_color) .. "&}") + ass:draw_start() + ass:rect_cw(0, 0, menu.width, line_height) + ass:draw_stop() + end + + ass:new_event() + if item.page_offset then + ass:an(5) + ass:pos(menu.x + options.padding_x + menu.width / 2, item_y) + else + ass:an(4) + ass:pos(menu.x + options.padding_x, item_y) + end + ass:append(style .. "{\\clip(0,0," .. menu.x + menu.width .. ",99999)}") + + if has_state(item, "disabled") then + ass:append("{\\1c&H" .. + color_option_to_ass(options.disabled_color) .. "&}") + elseif focused then + ass:append("{\\1c&H" .. + color_option_to_ass(options.focused_color) .. "&}") + end + + if has_state(item, "checked") then + ass:append("✔ ") + elseif menu.has_checkbox then + ass:append("{\\1a&HFF&}✔ {\\1a&}") + end + + ass:append(escape(item.title)) + + if item.submenu or item.shortcut then + ass:new_event() + ass:an(6) + ass:pos(menu.x + menu.width - options.padding_x, item_y) + ass:append(style) + + if has_state(item, "disabled") then + ass:append("{\\1c&H" .. + color_option_to_ass(options.disabled_color) .. "&}") + elseif focused then + ass:append("{\\1c&H" .. + color_option_to_ass(options.focused_color) .. "&}") + end + + ass:append(escape(get_right_aligned_text(item))) + end + + items[level][non_separator_item_index] = { + data = item, + x0 = menu.x, + x1 = menu.x + menu.width, + y0 = item_y - line_height / 2, + y1 = item_y + line_height / 2, + } +end + +local function add_submenu(ass, menu, level, style, background_style) + ass:new_event() + ass:an(7) + ass:pos(menu.x, menu.y) + ass:append(background_style) + ass:draw_start() + ass:round_rect_cw(0, 0, menu.width, menu.height, options.corner_radius, + options.corner_radius) + ass:draw_stop() + + local line_height = get_line_height() + + items[level] = {} + local first = 1 + local non_separator_item_index = 1 + + if menu.page > 1 then + local item_y = menu.y + options.padding_y + 0.5 * line_height + append_item(ass, menu, level, style, { title = '▴', page_offset = -1 }, + item_y, non_separator_item_index, line_height) + non_separator_item_index = 2 + + local i = 1 + local per_page = (menu.height - options.padding_y * 2) / get_line_height() - 2 + for _, item in ipairs(menu.items) do + if item.type ~= "separator" then + if i < (menu.page - 1) * per_page then + first = first + 1 + else + break + end + end + i = i + 1 + end + end + + for i = first, #menu.items do + local item = menu.items[i] + local item_y = menu.y + options.padding_y + (non_separator_item_index - 0.5) * line_height + + if item.type == "separator" then + ass:new_event() + ass:an(7) + ass:pos(menu.x, item_y - line_height / 2) + ass:append(style) + ass:draw_start() + ass:rect_cw(0, -1, menu.width, 0) + ass:draw_stop() + else + local add_down_arrow = i < #menu.items and item_y + line_height > menu.y + menu.height + if add_down_arrow then + item = { title = '▾', page_offset = 1 } + end + + append_item(ass, menu, level, style, item, item_y, + non_separator_item_index, line_height) + + if add_down_arrow then + return + end + + non_separator_item_index = non_separator_item_index + 1 + end + end +end + +local function render() + local ass = assdraw.ass_new() + local osd_w, osd_h = get_scaled_osd_dimensions() + local border_style = mp.get_property("osd-border-style") + + local style = "{\\fs" .. options.font_size .. "\\bord0\\4a&Hff&\\blur0\\q2}" + + local back_color = mpv_color_to_ass(mp.get_property( + border_style == "background-box" and "osd-back-color" or "osd-outline-color")) + + -- Don't make the background pure black in the default configuration + -- because it causes eye strain. It is fine for other UI elements because + -- they have background transparency, but the context menu doesn't. + if back_color == "000000" then + back_color = "222222" + end + + local background_style = "{\\1c&H" .. back_color .. "&" .. + "&\\bord" .. options.menu_outline_size .. "\\3c&H" .. + color_option_to_ass(options.menu_outline_color) .. "&}" + if border_style == "background-box" then + background_style = background_style .. "{\\4a&Hff&}" + end + + items = {} + + for i, open_menu in ipairs(open_menus) do + add_submenu(ass, open_menu, i, style, background_style) + end + + update_overlay(ass.text, osd_w, osd_h, 3000) +end + +local function determine_hovered_item() + local x, y = get_scaled_mouse_pos() + + for level = #items, 1, -1 do + for i, item in ipairs(items[level]) do + if x >= item.x0 and x <= item.x1 and + y >= item.y0 and y <= item.y1 then + return level, i + end + end + end + + return 1 +end + +local function open_submenu(update_focus) + local item = items[focused_level][focused_index] + + if not item or not item.data.submenu or not item.data.submenu[1] then + return + end + + clear_timers() + + for i = focused_level + 1, #open_menus do + open_menus[i] = nil + end + + open_menus[#open_menus].index_with_open_submenu = focused_index + add_menu(item.data.submenu, item.x1, item.y0 - options.padding_y) + + if update_focus then + focused_level = focused_level + 1 + focused_index = 0 + repeat + focused_index = focused_index + 1 + local new_item = item.data.submenu[focused_index] + until not new_item or not has_state(new_item, "disabled") + end + + render() +end + +local function handle_mouse_move() + local level, index = determine_hovered_item() + + if level == focused_level and index == focused_index then + return + end + + focused_level = level + focused_index = index + local item = items[level][index] + + render() + + if open_submenu_timer then + open_submenu_timer:kill() + open_submenu_timer = nil + end + + if item and item.data.submenu then + open_submenu_timer = mp.add_timeout(options.seconds_to_open_submenus, function () + open_submenu() + end) + end + + if item and level < #open_menus and not close_submenu_timer and + (item and item.submenu) ~= open_menus[#open_menus].items then + close_submenu_timer = mp.add_timeout(options.seconds_to_close_submenus, function () + for i = level + 1, #open_menus do + open_menus[i] = nil + end + + open_menus[#open_menus].index_with_open_submenu = nil + close_submenu_timer = nil + render() + end) + elseif close_submenu_timer and level == #open_menus then + close_submenu_timer:kill() + close_submenu_timer = nil + end +end + +local function move_focus(offset) + local item + + repeat + focused_index = (focused_index or 0) + offset + item = items[focused_level][focused_index] + until not item or not has_state(item.data, "disabled") + + if item then + render() + return + end + + focused_index = offset > 0 and 0 or #items[focused_level] + 1 + + repeat + focused_index = focused_index + offset + item = items[focused_level][focused_index] + until not item or not has_state(item.data, "disabled") + + render() +end + +local function focus_first() + focused_index = 0 + move_focus(1) +end + +local function focus_last() + focused_index = #items[focused_level] + 1 + move_focus(-1) +end + +local function close_submenu() + if focused_level == 1 then + return + end + + open_menus[#open_menus] = nil + focused_level = focused_level - 1 + focused_index = open_menus[#open_menus].index_with_open_submenu + open_menus[#open_menus].index_with_open_submenu = nil + + render() +end + +local function activate_focused_item(update_focus) + local item = items[focused_level][focused_index] + + if not item or has_state(item.data, "disabled") then + return + end + + if item.data.cmd then + mp.command(item.data.cmd) + close() + return + end + + if item.data.page_offset then + open_menus[#open_menus].page = open_menus[#open_menus].page + item.data.page_offset + render() + return + end + + open_submenu(update_focus) +end + +local function handle_click() + focused_level, focused_index = determine_hovered_item() + + if not focused_index then + close() + return + end + + activate_focused_item() +end + +local function activate_shortcut(info) + if info.event == "up" then + return + end + + if info.key_text == " " then + activate_focused_item(true) + end + + for i, item in ipairs(items[focused_level]) do + if (item.data.title or ""):sub(1, 1):lower() == info.key_text then + focused_index = i + activate_focused_item(true) + break + end + end +end + +local bindings = { + MOUSE_MOVE = handle_mouse_move, + MBTN_LEFT = handle_click, + MBTN_MID = handle_click, + MBTN_RIGHT = handle_click, + UP = function () move_focus(-1) end, + LEFT = close_submenu, + DOWN = function () move_focus(1) end, + RIGHT = function () open_submenu(true) end, + HOME = focus_first, + END = focus_last, + PGUP = focus_first, + PGDWN = focus_last, + ENTER = function () activate_focused_item(true) end, + ESC = function () close() end, + ANY_UNICODE = activate_shortcut, +} +for _, key in pairs({"UP", "DOWN", "LEFT", "RIGHT", "HOME", "END", "PGUP", + "PGDWN", "ENTER"}) do + bindings["KP_" .. key] = bindings[key] +end + +close = function () + update_overlay("", 0, 0, 0) + clear_timers() + focused_index = nil + + for key, _ in pairs(bindings) do + mp.remove_key_binding("_context_menu_" .. key) + end +end + +mp.register_script_message("open", function () + open_menus = {} + focused_level = 1 + + local x, y, hover = get_scaled_mouse_pos() + + if not hover then + x = 0 + y = 0 + end + + add_menu(mp.get_property_native("menu-data"), x, y) + + render() + + for key, fn in pairs(bindings) do + mp.add_forced_key_binding(key, "_context_menu_" .. key, fn, { + repeatable = true, + complex = key == "ANY_UNICODE", + }) + end +end) + +mp.register_script_message("select", activate_focused_item) + +require "mp.options".read_options(options) diff --git a/player/lua/meson.build b/player/lua/meson.build index 111e40228d829..72c92261fbaef 100644 --- a/player/lua/meson.build +++ b/player/lua/meson.build @@ -1,7 +1,7 @@ lua_files = ['defaults.lua', 'assdraw.lua', 'options.lua', 'osc.lua', 'ytdl_hook.lua', 'stats.lua', 'console.lua', 'auto_profiles.lua', 'input.lua', 'fzy.lua', 'select.lua', 'positioning.lua', - 'commands.lua'] + 'commands.lua', 'context_menu.lua'] foreach file: lua_files lua_file = custom_target(file, input: file, diff --git a/player/scripting.c b/player/scripting.c index 231ed8f10ad80..f5997d21b7186 100644 --- a/player/scripting.c +++ b/player/scripting.c @@ -270,6 +270,7 @@ void mp_load_builtin_scripts(struct MPContext *mpctx) load_builtin_script(mpctx, 5, mpctx->opts->lua_load_select, "@select.lua"); 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"); } bool mp_load_scripts(struct MPContext *mpctx)