Something went wrong. Try again.
🎥 Command line media player
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697--[[This file is part of mpv.
mpv is free software; you can redistribute it and/ormodify it under the terms of the GNU Lesser General PublicLicense as published by the Free Software Foundation; eitherversion 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 ofMERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See theGNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General PublicLicense 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 itemslocal focused_level = 1local focused_indexlocal open_submenu_timerlocal close_submenu_timerlocal overlay = mp.create_osd_overlay("ass-events")local width_overlay = mp.create_osd_overlay("ass-events")width_overlay.compute_bounds = truewidth_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 endend
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 / scaleend
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.hoverend
local function has_state(item, state) for _, value in pairs(item.state or {}) do if value == state then return true end endend
local function has_checkbox(menu_items) for _, item in pairs(menu_items) do if has_state(item, "checked") then return true end endend
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 = hintend
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 * 2end
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 endend
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 1end
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 and not has_state(item.data, "disabled") 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 endend
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() endend
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 endend
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() endend)
require "mp.options".read_options(options, nil, function () end)