SIGN IN SIGN UP
--[[
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 input = require "mp.input"
local options = {
history_date_format = "%Y-%m-%d %H:%M:%S",
hide_history_duplicates = true,
menu_conf_path = "~~/menu.conf",
max_playlist_items = 25,
populate_menu_data = true,
}
require "mp.options".read_options(options, nil, function () end)
local platform = mp.get_property("platform")
local function show_warning(message)
mp.msg.warn(message)
if mp.get_property_native("vo-configured") then
mp.osd_message(message)
end
end
local function show_error(message)
mp.msg.error(message)
if mp.get_property_native("vo-configured") then
mp.osd_message(message)
end
end
local function to_map(t)
local map = {}
for _, value in pairs(t) do
map[value] = true
end
return map
end
local function format_playlist_entry(entry, show)
local item = entry.title
if not item or show ~= "title" then
item = entry.filename
if not item:find("://") then
item = select(2, utils.split_path(item))
end
if entry.title and show == "both" then
item = entry.title .. " (" .. item .. ")"
end
end
return item
end
mp.add_key_binding(nil, "select-playlist", function ()
local playlist = {}
local default_item
local show = mp.get_property_native("osd-playlist-entry")
for i, entry in ipairs(mp.get_property_native("playlist")) do
playlist[i] = format_playlist_entry(entry, show)
if entry.playing then
default_item = i
end
end
if #playlist == 0 then
show_warning("The playlist is empty.")
return
end
input.select({
prompt = "Select a playlist entry:",
items = playlist,
default_item = default_item,
submit = function (index)
mp.commandv("playlist-play-index", index - 1)
end,
})
end)
local function format_track_type(track)
return (track.image
and "Image"
or track.type:sub(1, 1):upper() .. track.type:sub(2)) ..
": "
end
local function format_flags(track)
local flags = ""
for _, flag in ipairs({
"default", "forced", "dependent", "visual-impaired", "hearing-impaired",
"original", "commentary", "image", "external"
}) do
if track[flag] then
flags = flags .. flag .. " "
end
end
if flags == "" then
return ""
end
return " [" .. flags:sub(1, -2) .. "]"
end
local function format_track(track)
local bitrate = track["demux-bitrate"] or track["hls-bitrate"]
return (track.selected and "" or "") ..
(track.title and " " .. track.title or "") ..
" (" .. (
(track.lang and track.lang .. " " or "") ..
(track.codec and track.codec .. " " or "") ..
(track["demux-w"] and track["demux-w"] .. "x" .. track["demux-h"]
.. " " or "") ..
(track["demux-fps"] and not track.image
and string.format("%.4f", track["demux-fps"]):gsub("%.?0*$", "") ..
" fps " or "") ..
(track["demux-channel-count"] and track["demux-channel-count"] ..
"ch " or "") ..
(track["codec-profile"] and track.type == "audio"
and track["codec-profile"] .. " " or "") ..
(track["demux-samplerate"] and track["demux-samplerate"] / 1000 ..
" kHz " or "") ..
(bitrate and string.format("%.0f", bitrate / 1000) ..
" kbps " or "")
):sub(1, -2) .. ")" .. format_flags(track)
end
mp.add_key_binding(nil, "select-track", function ()
local tracks = {}
for i, track in ipairs(mp.get_property_native("track-list")) do
tracks[i] = format_track_type(track) .. format_track(track)
end
if #tracks == 0 then
show_warning("No available tracks.")
return
end
input.select({
prompt = "Select a track:",
items = tracks,
submit = function (id)
local track = mp.get_property_native("track-list/" .. id - 1)
if track then
mp.set_property(track.type, track.selected and "no" or track.id)
end
end,
})
end)
local function select_track(property, type, prompt, warning)
local tracks = {}
local items = {}
local default_item
local track_id = mp.get_property_native(property)
for _, track in ipairs(mp.get_property_native("track-list")) do
if track.type == type then
tracks[#tracks + 1] = track
items[#items + 1] = format_track(track)
if track.id == track_id then
default_item = #items
end
end
end
if #items == 0 then
show_warning(warning)
return
end
input.select({
prompt = prompt,
items = items,
default_item = default_item,
submit = function (id)
mp.set_property(property, tracks[id].selected and "no" or tracks[id].id)
end,
})
end
mp.add_key_binding(nil, "select-sid", function ()
select_track("sid", "sub", "Select a subtitle:", "No available subtitles.")
end)
mp.add_key_binding(nil, "select-secondary-sid", function ()
select_track("secondary-sid", "sub", "Select a secondary subtitle:",
"No available subtitles.")
end)
mp.add_key_binding(nil, "select-aid", function ()
select_track("aid", "audio", "Select an audio track:",
"No available audio tracks.")
end)
mp.add_key_binding(nil, "select-vid", function ()
select_track("vid", "video", "Select a video track:",
"No available video tracks.")
end)
local function format_time(t, duration)
local fmt = math.max(t, duration) >= 60 * 60 and "%H:%M:%S" or "%M:%S"
return mp.format_time(t, fmt)
end
mp.add_key_binding(nil, "select-chapter", function ()
local chapters = {}
local default_item = mp.get_property_native("chapter")
if default_item == nil then
show_warning("No available chapters.")
return
end
local duration = mp.get_property_native("duration", math.huge)
for i, chapter in ipairs(mp.get_property_native("chapter-list")) do
chapters[i] = format_time(chapter.time, duration) .. " " .. chapter.title
end
input.select({
prompt = "Select a chapter:",
items = chapters,
default_item = default_item > -1 and default_item + 1,
submit = function (chapter)
mp.set_property("chapter", chapter - 1)
end,
})
end)
local function format_edition(edition)
return edition.title or ("Edition " .. edition.id + 1)
end
2024-11-28 19:03:08 +01:00
mp.add_key_binding(nil, "select-edition", function ()
local edition_list = mp.get_property_native("edition-list")
2024-11-28 19:03:08 +01:00
if edition_list == nil or #edition_list < 2 then
show_warning("No available editions.")
2024-11-28 19:03:08 +01:00
return
end
local editions = {}
local default_item = mp.get_property_native("current-edition")
for i, edition in ipairs(edition_list) do
editions[i] = format_edition(edition)
2024-11-28 19:03:08 +01:00
end
input.select({
prompt = "Select an edition:",
items = editions,
default_item = default_item > -1 and default_item + 1,
2024-11-28 19:03:08 +01:00
submit = function (edition)
mp.set_property("edition", edition - 1)
end,
})
end)
mp.add_key_binding(nil, "select-subtitle-line", function ()
local sub = mp.get_property_native("current-tracks/sub")
if sub == nil then
show_warning("No subtitle is loaded.")
return
end
if sub.external and sub["external-filename"]:find("^edl://") then
sub["external-filename"] = sub["external-filename"]:match('https?://.*')
or sub["external-filename"]
end
local r = mp.command_native({
name = "subprocess",
capture_stdout = true,
args = sub.external
and {"ffmpeg", "-loglevel", "error", "-i", sub["external-filename"],
"-f", "lrc", "-map_metadata", "-1", "-fflags", "+bitexact", "-"}
or {"ffmpeg", "-loglevel", "error", "-i", mp.get_property("path"),
"-map", "s:" .. sub["id"] - 1, "-f", "lrc", "-map_metadata",
"-1", "-fflags", "+bitexact", "-"}
})
if r.error_string == "init" then
show_error("Failed to extract subtitles: ffmpeg not found.")
return
elseif r.status ~= 0 then
show_error("Failed to extract subtitles.")
return
end
local sub_lines = {}
local sub_times = {}
local default_item
local delay = mp.get_property_native("sub-delay")
local time_pos = mp.get_property_native("time-pos") - delay
local duration = mp.get_property_native("duration", math.huge)
local sub_content = {}
-- Strip HTML and ASS tags and process subtitles
for line in r.stdout:gmatch("[^\n]+") do
-- Clean up tags
local sub_line = line:gsub("<.->", "") -- Strip HTML tags
:gsub("\\h+", " ") -- Replace '\h' tag
:gsub("{[\\=].-}", "") -- Remove ASS formatting
:gsub(".-]", "", 1) -- Remove time info prefix
:gsub("^%s*(.-)%s*$", "%1") -- Strip whitespace
:gsub("^m%s[mbl%s%-%d%.]+$", "") -- Remove graphics code
if sub.codec == "text" or (sub_line ~= "" and sub_line:match("^%s+$") == nil) then
local sub_time = line:match("%d+") * 60 + line:match(":([%d%.]*)")
local time_seconds = math.floor(sub_time)
sub_content[time_seconds] = sub_content[time_seconds] or {}
sub_content[time_seconds][sub_line] = true
end
end
-- Process all timestamps and content into selectable subtitle list
for time_seconds, contents in pairs(sub_content) do
for sub_line in pairs(contents) do
sub_times[#sub_times + 1] = time_seconds
sub_lines[#sub_lines + 1] = format_time(time_seconds, duration) .. " " .. sub_line
end
end
-- Generate time -> subtitle mapping
local time_to_lines = {}
for i = 1, #sub_times do
local time = sub_times[i]
local line = sub_lines[i]
if not time_to_lines[time] then
time_to_lines[time] = {}
end
table.insert(time_to_lines[time], line)
end
-- Sort by timestamp
local sorted_sub_times = {}
for i = 1, #sub_times do
sorted_sub_times[i] = sub_times[i]
end
table.sort(sorted_sub_times)
-- Use a helper table to avoid duplicates
local added_times = {}
-- Rebuild sub_lines and sub_times based on the sorted timestamps
local sorted_sub_lines = {}
for _, sub_time in ipairs(sorted_sub_times) do
-- Iterate over all subtitle content for this timestamp
if not added_times[sub_time] then
added_times[sub_time] = true
for _, line in ipairs(time_to_lines[sub_time]) do
table.insert(sorted_sub_lines, line)
end
end
end
-- Use the sorted subtitle list
sub_lines = sorted_sub_lines
sub_times = sorted_sub_times
-- Get the default item (last subtitle before current time position)
for i, sub_time in ipairs(sub_times) do
if sub_time <= time_pos then
default_item = i
end
end
input.select({
prompt = "Select a line to seek to:",
items = sub_lines,
default_item = default_item,
submit = function (index)
-- Add an offset to seek to the correct line while paused without a
-- video track.
if mp.get_property_native("current-tracks/video/image") ~= false then
delay = delay + 0.1
end
mp.commandv("seek", sub_times[index] + delay, "absolute")
end,
})
end)
local function format_audio_device(device)
return device.name .. " (" .. device.description .. ")"
end
mp.add_key_binding(nil, "select-audio-device", function ()
local devices = mp.get_property_native("audio-device-list")
local items = {}
-- This is only useful if an --audio-device has been explicitly set,
-- otherwise its value is just auto and there is no current-audio-device
-- property.
local selected_device = mp.get_property("audio-device")
local default_item
if #devices == 0 then
show_warning("No available audio devices.")
return
end
for i, device in ipairs(devices) do
items[i] = format_audio_device(device)
if device.name == selected_device then
default_item = i
end
end
input.select({
prompt = "Select an audio device:",
items = items,
default_item = default_item,
submit = function (id)
mp.set_property("audio-device", devices[id].name)
end,
})
end)
local function format_history_entry(entry)
local status
status, entry.time = pcall(os.date, options.history_date_format, entry.time)
if not status then
mp.msg.warn(entry.time)
end
local item = "(" .. entry.time .. ") "
if entry.title then
return item .. entry.title .. " (" .. entry.path .. ")"
end
if entry.path:find("://") then
return item .. entry.path
end
local directory, filename = utils.split_path(entry.path)
return item .. filename .. " (" .. directory .. ")"
end
mp.add_key_binding(nil, "select-watch-history", function ()
local history_file_path = mp.command_native(
{"expand-path", mp.get_property("watch-history-path")})
local history_file, error_message = io.open(history_file_path)
if not history_file then
show_warning(mp.get_property_native("save-watch-history")
and error_message
or "Enable --save-watch-history to jump to recently played files.")
return
end
local all_entries = {}
local line_num = 1
for line in history_file:lines() do
local entry = utils.parse_json(line)
if entry and entry.path then
all_entries[#all_entries + 1] = entry
else
mp.msg.warn(history_file_path .. ": Parse error at line " .. line_num)
end
line_num = line_num + 1
end
history_file:close()
local entries = {}
local items = {}
local seen = {}
for i = #all_entries, 1, -1 do
local entry = all_entries[i]
if not seen[entry.path] or not options.hide_history_duplicates then
seen[entry.path] = true
entries[#entries + 1] = entry
items[#items + 1] = format_history_entry(entry)
end
end
items[#items+1] = "Clear history"
input.select({
prompt = "Select a file:",
items = items,
submit = function (i)
if entries[i] then
mp.commandv("loadfile", entries[i].path)
return
end
error_message = select(2, os.remove(history_file_path))
if error_message then
show_error(error_message)
else
mp.osd_message("History cleared.")
end
end,
})
end)
mp.add_key_binding(nil, "select-watch-later", function ()
local watch_later_dir = mp.get_property("current-watch-later-dir")
if not watch_later_dir then
show_warning("No watch later files found.")
return
end
local watch_later_files = {}
for i, file in ipairs(utils.readdir(watch_later_dir, "files") or {}) do
watch_later_files[i] = watch_later_dir .. "/" .. file
end
if #watch_later_files == 0 then
show_warning("No watch later files found.")
return
end
local files = {}
for _, watch_later_file in pairs(watch_later_files) do
local file_handle = io.open(watch_later_file)
if file_handle then
local line = file_handle:read()
if line and line ~= "# redirect entry" and line:find("^#") then
files[#files + 1] = {line:sub(3), utils.file_info(watch_later_file).mtime}
end
file_handle:close()
end
end
if #files == 0 then
show_warning(mp.get_property_native("write-filename-in-watch-later-config")
and "No watch later files found."
or "Enable --write-filename-in-watch-later-config to select recent files.")
return
end
table.sort(files, function (i, j)
return i[2] > j[2]
end)
local items = {}
for i, file in ipairs(files) do
items[i] = os.date("(%Y-%m-%d) ", file[2]) .. file[1]
end
input.select({
prompt = "Select a file:",
items = items,
submit = function (i)
mp.commandv("loadfile", files[i][1])
end,
})
end)
local function get_active_bindings()
local bindings = {}
for _, binding in pairs(mp.get_property_native("input-bindings")) do
if binding.priority >= 0 and (
bindings[binding.key] == nil or
(bindings[binding.key].is_weak and not binding.is_weak) or
(binding.is_weak == bindings[binding.key].is_weak and
binding.priority > bindings[binding.key].priority)
) and not binding.section:find("^input_forced_")
-- OSC sections
and binding.section ~= "input"
and binding.section ~= "window-controls" then
bindings[binding.key] = binding
end
end
return bindings
end
mp.add_key_binding(nil, "select-binding", function ()
local items = {}
for _, binding in pairs(get_active_bindings()) do
if binding.cmd ~= "ignore" then
items[#items + 1] = binding.key .. " " .. binding.cmd
end
end
table.sort(items)
input.select({
prompt = "Select a binding:",
items = items,
submit = function (i)
mp.command(items[i]:gsub("^.- ", ""))
end,
})
end)
local properties = {}
local function add_property(property, value)
value = value or mp.get_property_native(property)
if type(value) == "table" and next(value) then
for key, val in pairs(value) do
add_property(property .. "/" .. key, val)
end
else
properties[#properties + 1] = property .. ": " .. utils.to_string(value)
end
end
mp.add_key_binding(nil, "show-properties", function ()
properties = {}
-- Don't log errors for renamed and removed properties.
local msg_level_backup = mp.get_property("msg-level")
mp.set_property("msg-level", msg_level_backup == "" and "cplayer=no"
or msg_level_backup .. ",cplayer=no")
for _, property in pairs(mp.get_property_native("property-list")) do
add_property(property)
end
mp.set_property("msg-level", msg_level_backup)
add_property("current-tracks/audio")
add_property("current-tracks/video")
add_property("current-tracks/sub")
add_property("current-tracks/sub2")
table.sort(properties)
input.select({
prompt = "Inspect a property:",
items = properties,
submit = function (i)
if mp.get_property_native("vo-configured") then
mp.commandv("expand-properties", "show-text",
(#properties[i] > 100 and
"${osd-ass-cc/0}{\\fs9}${osd-ass-cc/1}" or "") ..
"$>" .. properties[i], 20000)
else
mp.msg.info(properties[i])
end
end,
})
end)
2025-01-08 11:56:13 +01:00
local function system_open(path)
local args
if platform == "windows" then
args = {"rundll32", "url.dll,FileProtocolHandler", path}
elseif platform == "darwin" then
args = {"open", path}
else
args = {"gio", "open", path}
end
mp.commandv("run", unpack(args))
end
local function edit_config_file(filename)
if not mp.get_property_bool("config") then
show_warning("Editing config files with --no-config is not supported.")
return
end
local path = mp.find_config_file(filename)
if not path then
path = mp.command_native({"expand-path", "~~/" .. filename})
local file_handle, error_message = io.open(path, "w")
if not file_handle then
show_error(error_message)
return
end
file_handle:close()
end
system_open(path)
end
mp.add_key_binding(nil, "edit-config-file", function ()
edit_config_file("mpv.conf")
end)
mp.add_key_binding(nil, "edit-input-conf", function ()
edit_config_file("input.conf")
end)
mp.add_key_binding(nil, "open-docs", function ()
system_open("https://mpv.io/manual/")
end)
mp.add_key_binding(nil, "open-chat", function ()
system_open("https://web.libera.chat/#mpv")
end)
2025-01-08 11:56:13 +01:00
mp.add_key_binding(nil, "menu", function ()
local sub_track_count = 0
local audio_track_count = 0
local video_track_count = 0
local text_sub_selected = false
local is_disc = mp.get_property("current-demuxer") == "disc"
local image_sub_codecs = to_map({"dvb_subtitle", "dvd_subtitle", "hdmv_pgs_subtitle"})
2025-01-08 11:56:13 +01:00
for _, track in pairs(mp.get_property_native("track-list")) do
if track.type == "sub" then
sub_track_count = sub_track_count + 1
if track["main-selection"] == 0 and not image_sub_codecs[track.codec] then
text_sub_selected = true
end
elseif track.type == "audio" then
audio_track_count = audio_track_count + 1
elseif track.type == "video" then
video_track_count = video_track_count + 1
end
end
local menu = {
{"Subtitles", "script-binding select/select-sid", sub_track_count > 0},
{"Secondary subtitles", "script-binding select/select-secondary-sid", sub_track_count > 1},
{"Subtitle lines", "script-binding select/select-subtitle-line", text_sub_selected},
{"Audio tracks", "script-binding select/select-aid", audio_track_count > 1},
{"Video tracks", "script-binding select/select-vid", video_track_count > 1},
{"Playlist", "script-binding select/select-playlist",
mp.get_property_native("playlist-count") > 1},
{"Chapters", "script-binding select/select-chapter", mp.get_property("chapter")},
{is_disc and "Titles" or "Editions", "script-binding select/select-edition",
mp.get_property_native("edition-list/count", 0) > 1},
{"Audio devices", "script-binding select/select-audio-device", audio_track_count > 0},
{"Key bindings", "script-binding select/select-binding", true},
{"History", "script-binding select/select-watch-history", true},
{"Watch later", "script-binding select/select-watch-later", true},
{"Playback statistics", "script-binding stats/display-page-1-toggle", true},
{"File information", "script-binding stats/display-page-5-toggle",
mp.get_property("filename")},
{"Edit config file", "script-binding select/edit-config-file", true},
{"Edit key bindings", "script-binding select/edit-input-conf", true},
2025-01-08 11:56:13 +01:00
{"Help", "script-binding stats/display-page-4-toggle", true},
{"Online documentation", "script-binding select/open-docs", true},
{"Support", "script-binding select/open-chat", true},
2025-01-08 11:56:13 +01:00
}
local labels = {}
local commands = {}
for _, entry in ipairs(menu) do
if entry[3] then
labels[#labels + 1] = entry[1]
commands[#commands + 1] = entry[2]
end
end
input.select({
items = labels,
keep_open = true,
2025-01-08 11:56:13 +01:00
submit = function (i)
mp.command(commands[i])
if not commands[i]:find("^script%-binding select/select") then
input.terminate()
end
2025-01-08 11:56:13 +01:00
end,
})
end)
local menu = {} -- contains wrappers of menu_data's items
local menu_data = {}
local observed_properties = {}
local property_cache = {}
local active_bindings = {}
local property_set = {}
local property_items = {}
local have_dirty_items = false
local current_item
local function on_property_change(name, value)
property_cache[name] = value
if property_items[name] then
for item, _ in pairs(property_items[name]) do
item.dirty = true
end
have_dirty_items = true
end
end
local none_getters = {
-- Only retrieve playlist when playlist-count is not huge to not kill
-- performance. playlist is observed with type none to receive every
-- MP_EVENT_CHANGE_PLAYLIST.
playlist = function ()
if mp.get_property_native("playlist-count") < 100 then
return mp.get_property_native("playlist")
end
end,
}
local function on_none_property_change(name)
on_property_change(name, none_getters[name]())
end
function _G.get(name, default)
if not observed_properties[name] then
local result, err = (none_getters[name] or mp.get_property_native)(name)
if err == "property not found" and not property_set(name:match("^([^/]+)")) then
mp.msg.error("Property '" .. name .. "' was not found.")
return default
end
observed_properties[name] = true
property_cache[name] = result
if none_getters[name] then
mp.observe_property(name, "none", on_none_property_change)
else
mp.observe_property(name, "native", on_property_change)
end
end
if current_item then
if not property_items[name] then
property_items[name] = {}
end
property_items[name][current_item] = true
end
if property_cache[name] == nil then
return default
end
return property_cache[name]
end
local function magic_get(name)
return get(name:gsub("_", "-"), nil)
end
local evil_magic = {}
setmetatable(evil_magic, {
__index = function(_, key)
if _G[key] ~= nil then
return _G[key]
end
return magic_get(key)
end,
})
_G.p = {}
setmetatable(p, {
__index = function(_, key)
return magic_get(key)
end,
})
local function compile_condition(chunk, chunkname)
chunk = "return " .. chunk
chunkname = 'Menu entry "' .. chunkname .. '"'
local compiled_chunk, err
-- luacheck: push
-- luacheck: ignore setfenv loadstring
if setfenv then -- lua 5.1
compiled_chunk, err = loadstring(chunk, chunkname)
if compiled_chunk then
setfenv(compiled_chunk, evil_magic)
end
else -- lua 5.2
compiled_chunk, err = load(chunk, chunkname, "t", evil_magic)
end
-- luacheck: pop
if not compiled_chunk then
mp.msg.error(chunkname .. " : " .. err)
compiled_chunk = function() return false end
end
return compiled_chunk
end
local function evaluate_condition(chunk, chunkname)
local status, result
status, result = pcall(chunk)
if not status then
mp.msg.verbose(chunkname .. " error on evaluating: " .. result)
return false
end
return not not result
end
local function toggle_state(states, state, add)
for i, existing_state in ipairs(states) do
if existing_state == state then
if add then
return
end
table.remove(states, i)
end
end
if add then
states[#states + 1] = state
end
end
local function on_idle()
if not have_dirty_items then
return
end
have_dirty_items = false
for _, item in pairs(menu) do
if item.dirty then
item:update()
item.dirty = false
end
end
mp.set_property_native("menu-data", menu_data)
end
-- quote string and escape it in JSON-style
local function quote(str)
return utils.format_json(str or "")
end
local function clamp_submenu(submenu, max, cmd)
if #submenu <= max then
return submenu
end
local mid = 1
for i, item in pairs(submenu) do
if item.state then
mid = i
break
end
end
local offset = math.floor(max / 2)
local first = mid + 1 - offset
local last = mid + offset
if first < 1 then
first = 1
last = max
end
if last > #submenu then
first = math.max(#submenu - max + 1, 1)
last = #submenu
end
local clamped = {}
if first > 1 then
clamped[1] = {
title = "",
cmd = cmd,
shortcut = first - 1 .. " more",
}
end
for i = first, last do
clamped[#clamped + 1] = submenu[i]
end
if last < #submenu then
clamped[#clamped + 1] = {
title = "",
cmd = cmd,
shortcut = #submenu - last .. " more",
}
end
return clamped
end
local function playlist()
local show = get("osd-playlist-entry")
if not get("playlist") then
return
end
local items = {}
for i, entry in ipairs(get("playlist")) do
items[i] = {
title = format_playlist_entry(entry, show):gsub("&", "&&"),
cmd = "playlist-play-index " .. (i - 1)
}
if entry.current then
items[i].state = {"checked"}
end
end
return clamp_submenu(items, options.max_playlist_items,
"script-binding select/select-playlist")
end
local function tracks(property, track_type)
local items = {}
local track_list = get("track-list")
local last_type
local positions = {video = 0, audio = 1, sub = 2}
table.sort(track_list, function (i, j)
if i.type ~= j.type then
return positions[i.type] < positions[j.type]
end
return i.id < j.id
end)
for _, track in ipairs(track_list) do
if not track_type or track.type == track_type then
if last_type and track.type ~= last_type then
items[#items + 1] = { type = "separator" }
end
last_type = track.type
items[#items + 1] = {
title = (track_type and "" or format_track_type(track)) ..
-- Remove the circles since checkmarks are already added.
format_track(track):sub(5):gsub("&", "&&"),
cmd = "set " .. (property or track.type) .. " " .. track.id,
}
if track.selected then
items[#items].cmd = "set " .. (property or track.type) .. " no"
items[#items].state = {"checked"}
end
end
end
return items
end
local function chapters()
local items = {}
local current_chapter = get("chapter", -1)
local duration = mp.get_property_native("duration", math.huge)
for i, chapter in ipairs(get("chapter-list")) do
items[i] = {
title = chapter.title:gsub("&", "&&"),
cmd = "set chapter " .. (i - 1),
shortcut = format_time(chapter.time, duration),
}
if i == current_chapter + 1 then
items[i].state = {"checked"}
end
end
return items
end
local function editions()
local items = {}
local current_edition = get("current-edition", -1)
for i, edition in ipairs(get("edition-list", {})) do
items[i] = {
title = format_edition(edition):gsub("&", "&&"),
cmd = "set edition " .. (i - 1),
}
if i == current_edition + 1 then
items[i].state = {"checked"}
end
end
return items
end
local function audio_devices()
local items = {}
local selected_device = get("audio-device")
for i, device in ipairs(get("audio-device-list")) do
items[i] = {
title = format_audio_device(device):gsub("&", "&&"),
cmd = "set audio-device " .. quote(device.name),
}
if device.name == selected_device then
items[i].state = {"checked"}
end
end
return items
end
local function profiles()
local builtin_profiles = to_map({
"box", "builtin-pseudo-gui", "default", "encoding", "fast", "gpu-hq",
"high-quality", "libmpv", "low-latency", "osd-box", "pseudo-gui",
"sub-box", "sw-fast"
})
local user_profiles = {}
for _, profile in ipairs(get("profile-list")) do
if not builtin_profiles[profile.name] then
user_profiles[#user_profiles + 1] = profile.name
end
end
table.sort(user_profiles)
local items = {}
for i, profile in ipairs({
"fast", "high-quality", "osd-box", "sub-box", "box",
}) do
items[i] = {
title = profile,
cmd = "apply-profile " .. profile,
}
end
if user_profiles[1] then
items[#items + 1] = { type = "separator" }
end
for _, profile in ipairs(user_profiles) do
items[#items + 1] = {
title = profile:gsub("&", "&&"),
cmd = "apply-profile " .. quote(profile),
}
end
return items
end
local builtin_submenus = {
["$playlist"] = playlist,
["$tracks"] = function () return tracks() end,
["$video-tracks"] = function () return tracks("video", "video") end,
["$audio-tracks"] = function () return tracks("audio", "audio") end,
["$sub-tracks"] = function () return tracks("sub", "sub") end,
["$secondary-sub-tracks"] = function () return tracks("secondary-sid", "sub") end,
["$chapters"] = chapters,
["$editions"] = editions,
["$audio-devices"] = audio_devices,
["$profiles"] = profiles,
}
local submenu_commands = {
["$playlist"] = "script-binding select/select-playlist",
["$tracks"] = "script-binding select/select-track",
["$video-tracks"] = "script-binding select/select-vid",
["$audio-tracks"] = "script-binding select/select-aid",
["$sub-tracks"] = "script-binding select/select-sid",
["$secondary-sub-tracks"] = "script-binding select/select-secondary-sid",
["$chapters"] = "script-binding select/select-chapter",
["$editions"] = "script-binding select/select-edition",
["$audio-devices"] = "script-binding select/select-audio-device",
}
local function get_shortcut(cmd)
local shortcuts = {}
local uncommon_keys = to_map({
"MBTN_BACK", "MBTN_FORWARD", "POWER", "PLAY", "PAUSE", "PLAYPAUSE",
"PLAYONLY", "PAUSEONLY", "STOP", "FORWARD", "REWIND", "NEXT", "PREV",
"VOLUME_UP", "VOLUME_DOWN", "MUTE", "CLOSE_WIN",
})
for _, binding in pairs(active_bindings) do
if binding.cmd == cmd and not uncommon_keys[binding.key]
and not binding.key:find("KP_") then
shortcuts[#shortcuts + 1] = binding.key
end
end
return table.concat(shortcuts, ",")
end
local function update_builtin_submenu(item)
item.item.submenu = builtin_submenus[item.builtin_submenu]()
-- With huge playlists, make the menu item open the playlist menu
-- instead to not kill performance.
if not item.item.submenu then
item.item.cmd = submenu_commands[item.builtin_submenu]
item.item.type = nil
return
end
item.item.cmd = nil
item.item.type = "submenu"
local min = item.builtin_submenu == "$editions" and 2 or 1
item.item.state = #item.item.submenu < min and {"disabled"} or {}
end
local function update_state(item)
for state, compiled_condition in pairs(item.compiled_conditions) do
toggle_state(item.item.state, state,
evaluate_condition(compiled_condition, item.item.title))
end
end
local function parse_menu_item(line)
local tokens = {}
local separator = "\t+"
for token in line:gmatch("(.-)" .. separator) do
tokens[#tokens + 1] = token
end
tokens[#tokens + 1] = line:gsub(".*" .. separator, "")
if tokens[1] == "" then
return { type = "separator" }
end
local item = {
item = {
title = tokens[1],
state = {},
},
compiled_conditions = {},
}
current_item = item
if builtin_submenus[tokens[2]] then
item.builtin_submenu = tokens[2]
item.item.shortcut = get_shortcut(submenu_commands[item.builtin_submenu])
item.update = update_builtin_submenu
item:update()
return item
end
local state_start = 3
for _, state in pairs({"checked", "disabled", "hidden"}) do
if not tokens[2] or tokens[2]:find("^%s*" .. state .. "=") then
state_start = 2
break
end
end
if state_start == 2 then
item.item.type = "submenu"
item.item.submenu = {}
else
item.item.cmd = tokens[2]
item.item.shortcut = get_shortcut(tokens[2])
end
for i = state_start, #tokens do
local state, condition = tokens[i]:match("(%S-)=(.*)")
if not state then
return false
end
item.compiled_conditions[state] = compile_condition(condition, tokens[1])
if evaluate_condition(item.compiled_conditions[state], tokens[1]) then
table.insert(item.item.state, state)
end
end
item.update = update_state
return item
end
local function get_menu_conf()
local menu_conf
local file_handle = io.open(mp.command_native({"expand-path", options.menu_conf_path}))
if file_handle then
menu_conf = file_handle:read("*a")
file_handle:close()
else
menu_conf = mp.get_property("default-menu")
end
local lines = {}
for line in menu_conf:gmatch("(.-)\r?\n") do
lines[#lines + 1] = line
end
return lines
end
local function parse_menu_conf(_, vo_configured)
if not vo_configured then
return
end
mp.unobserve_property(parse_menu_conf)
property_set = to_map(mp.get_property_native("property-list"))
active_bindings = get_active_bindings()
local lines = get_menu_conf()
local last_leading_whitespace = ""
local menus_by_depth = { [""] = menu_data }
for i, line in ipairs(lines) do
local leading_whitespace = line:match("^%s*")
local item = parse_menu_item(line:gsub("^%s*", ""))
if not item then
show_error("menu.conf is malformed: " .. line ..
" contains tabs not used as separators")
return
end
if item.item then
menu[#menu + 1] = item
item = item.item
end
if #leading_whitespace > #last_leading_whitespace then
local last_menu = menus_by_depth[last_leading_whitespace]
if not last_menu[#last_menu].submenu then
show_error("menu.conf is malformed: " .. line ..
" has leading whitespace but no parent menu was defined")
return
end
menus_by_depth[leading_whitespace] = last_menu[#last_menu].submenu
end
if line == "" then
-- Determine the depth of the separator from the next line.
if lines[i + 1] then -- ignore newlines at the end
table.insert(menus_by_depth[lines[i + 1]:match("%s*")], item)
end
else
table.insert(menus_by_depth[leading_whitespace], item)
last_leading_whitespace = leading_whitespace
end
end
property_set = nil
active_bindings = nil
current_item = nil
mp.register_idle(on_idle)
end
if options.populate_menu_data then
mp.observe_property("vo-configured", "native", parse_menu_conf)
end
mp.add_key_binding(nil, "context-menu", function (info)
if info.event == "repeat" then
return
end
if not info.is_mouse then
if info.event == "down" or info.event == "press" then
mp.command(mp.get_property_native("load-context-menu")
and "script-message-to context_menu open"
or "context-menu")
end
elseif mp.get_property_native("load-context-menu") then
mp.commandv("script-message-to", "context_menu",
info.event == "down" and "open" or "select")
elseif info.event == (platform == "windows" and "up" or "down") then
mp.command("context-menu")
end
end, { complex = true })