mirror of
https://github.com/mpv-player/mpv.git
synced 2026-05-07 20:02:49 +00:00
e046cd0736
Closes #17807.
697 lines
19 KiB
Lua
697 lines
19 KiB
Lua
--[[
|
|
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 <http://www.gnu.org/licenses/>.
|
|
]]
|
|
|
|
local utils = require "mp.utils"
|
|
local assdraw = require "mp.assdraw"
|
|
|
|
local options = {
|
|
font_size = 14,
|
|
gap = 0.2,
|
|
padding_x = 8,
|
|
padding_y = 4,
|
|
menu_outline_size = 0,
|
|
menu_outline_color = "#FFFFFF",
|
|
background_alpha = 0,
|
|
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 set_hint(item)
|
|
if not item.title then
|
|
return
|
|
end
|
|
|
|
local hint_pos, _, hint = item.title:find("^&([^&])")
|
|
|
|
if not hint_pos then
|
|
hint_pos, _, hint = item.title:find("[^&]&([^&])")
|
|
if hint_pos then
|
|
hint_pos = hint_pos + 1
|
|
end
|
|
end
|
|
|
|
item.hint_pos = hint_pos
|
|
item.hint = hint
|
|
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 = ""
|
|
local longest_width = 0
|
|
for _, title in pairs(titles) do
|
|
local title_width = utils.terminal_display_width(title)
|
|
if title_width > longest_width then
|
|
longest = title
|
|
longest_width = title_width
|
|
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 * (1 + 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)
|
|
local visible_items = {}
|
|
for _, item in ipairs(menu_items) do
|
|
if not has_state(item, "hidden") then
|
|
visible_items[#visible_items + 1] = item
|
|
set_hint(item)
|
|
end
|
|
end
|
|
|
|
if not visible_items[1] then
|
|
return
|
|
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
|
|
|
|
if item.hint then
|
|
ass:append(escape(item.title:sub(1, item.hint_pos - 1):gsub("&&", "&")) ..
|
|
"{\\u1}" .. item.hint .. "{\\u}" ..
|
|
escape(item.title:sub(item.hint_pos + 2):gsub("&&", "&")))
|
|
else
|
|
ass:append(escape(item.title:gsub("&&", "&")))
|
|
end
|
|
|
|
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 .. "{\\1c&H" ..
|
|
color_option_to_ass(options.disabled_color) .. "&}")
|
|
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 ..
|
|
"&\\1a&H" .. string.format("%x", options.background_alpha) ..
|
|
"&\\bord" .. options.menu_outline_size .. "\\3c&H" ..
|
|
color_option_to_ass(options.menu_outline_color) ..
|
|
"\\blur0&}"
|
|
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 handle_esc()
|
|
if focused_level > 1 then
|
|
close_submenu()
|
|
else
|
|
close()
|
|
end
|
|
end
|
|
|
|
local function activate_shortcut(info)
|
|
if info.event == "up" then
|
|
return
|
|
end
|
|
|
|
if info.key_text == " " then
|
|
activate_focused_item(true)
|
|
return
|
|
end
|
|
|
|
for i, item in ipairs(items[focused_level]) do
|
|
if (item.data.hint or ""):lower() == info.key_text:lower() 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 = handle_esc,
|
|
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
|
|
|
|
mp.set_property_native("user-data/mpv/context-menu/open", false)
|
|
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)
|
|
|
|
if not open_menus[1] then
|
|
return
|
|
end
|
|
|
|
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
|
|
|
|
mp.set_property_native("user-data/mpv/context-menu/open", true)
|
|
end)
|
|
|
|
mp.register_script_message("select", function ()
|
|
if open_menus[1] then
|
|
activate_focused_item()
|
|
end
|
|
end)
|
|
|
|
require "mp.options".read_options(options, nil, function () end)
|