It was suggested by CogentRedTester in https://github.com/mpv-player/mpv/pull/10282#issuecomment-1858727729 and https://github.com/mpv-player/mpv/pull/10282#issuecomment-1858809580 and by avih that making running commands an mp.input client is a better architecture. A practical advantage is that completions are calculated in a different thread, which prevents hanging when completing slow/network filesystems. script-binding console/enable becomes script-binding commands/open, though the console one is kept as an alias. I took the opportunity to rename this because open makes more sense for a graphical modal, and it is the word used in mp.input and user-data. script-message-to console type becomes script-message-to commands type, though the console one is kept as an alias. It is also changed to automatically close on submit without having to append '; keypress ESC' as you don't need to keep the console open after running prefilled commands. Also convert to double quotes like other scripts and rename some inconsistent functions.
567 lines
18 KiB
Lua
567 lines
18 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 options = {
|
|
persist_history = false,
|
|
history_path = "~~state/command_history.txt",
|
|
}
|
|
|
|
local input = require "mp.input"
|
|
local utils = require "mp.utils"
|
|
|
|
local styles = {
|
|
-- Colors are stolen from base16 Eighties by Chris Kempson
|
|
-- and converted to BGR as is required by ASS.
|
|
-- 2d2d2d 393939 515151 697374
|
|
-- 939fa0 c8d0d3 dfe6e8 ecf0f2
|
|
-- 7a77f2 5791f9 66ccff 99cc99
|
|
-- cccc66 cc9966 cc99cc 537bd2
|
|
|
|
debug = "{\\1c&Ha09f93&}",
|
|
v = "{\\1c&H99cc99&}",
|
|
warn = "{\\1c&H66ccff&}",
|
|
error = "{\\1c&H7a77f2&}",
|
|
fatal = "{\\1c&H5791f9&}",
|
|
completion = "{\\1c&Hcc99cc&}",
|
|
}
|
|
for key, style in pairs(styles) do
|
|
styles[key] = style .. "{\\3c&H111111&}"
|
|
end
|
|
|
|
local terminal_styles = {
|
|
debug = "\027[90m",
|
|
v = "\027[32m",
|
|
warn = "\027[33m",
|
|
error = "\027[31m",
|
|
fatal = "\027[91m",
|
|
}
|
|
|
|
local platform = mp.get_property("platform")
|
|
local path_separator = platform == "windows" and "\\" or "/"
|
|
local completion_pos
|
|
local completion_append
|
|
local last_text
|
|
local last_cursor_position
|
|
local commands
|
|
|
|
local function get_commands()
|
|
if not commands then
|
|
commands = mp.get_property_native("command-list")
|
|
end
|
|
|
|
return commands
|
|
end
|
|
|
|
local function help_command(param)
|
|
table.sort(get_commands(), function(c1, c2)
|
|
return c1.name < c2.name
|
|
end)
|
|
|
|
local output = ""
|
|
if param == "" then
|
|
output = "Available commands:\n"
|
|
for _, cmd in ipairs(get_commands()) do
|
|
output = output .. " " .. cmd.name
|
|
end
|
|
output = output .. "\n"
|
|
output = output .. 'Use "help command" to show information about a command.\n'
|
|
output = output .. "ESC or Ctrl+d exits the console.\n"
|
|
else
|
|
local cmd = nil
|
|
for _, curcmd in ipairs(get_commands()) do
|
|
if curcmd.name:find(param, 1, true) then
|
|
cmd = curcmd
|
|
if curcmd.name == param then
|
|
break -- exact match
|
|
end
|
|
end
|
|
end
|
|
|
|
if not cmd then
|
|
input.log('No command matches "' .. param .. '"!', styles.error,
|
|
terminal_styles.error)
|
|
return
|
|
end
|
|
|
|
output = output .. 'Command "' .. cmd.name .. '"\n'
|
|
|
|
for _, arg in ipairs(cmd.args) do
|
|
output = output .. " " .. arg.name .. " (" .. arg.type .. ")"
|
|
if arg.optional then
|
|
output = output .. " (optional)"
|
|
end
|
|
output = output .. "\n"
|
|
end
|
|
|
|
if cmd.vararg then
|
|
output = output .. "This command supports variable arguments.\n"
|
|
end
|
|
end
|
|
|
|
input.log(output:sub(1, -2))
|
|
end
|
|
|
|
local function submit(line)
|
|
-- match "help [<text>]", return <text> or "", strip all whitespace
|
|
local help = line:match("^%s*help%s+(.-)%s*$") or
|
|
(line:match("^%s*help$") and "")
|
|
|
|
if help then
|
|
help_command(help)
|
|
elseif line ~= "" then
|
|
mp.command(line)
|
|
end
|
|
end
|
|
|
|
local function opened()
|
|
mp.enable_messages("terminal-default")
|
|
end
|
|
|
|
local function closed(text, cursor_position)
|
|
mp.enable_messages("silent:terminal-default")
|
|
|
|
last_text = text
|
|
last_cursor_position = cursor_position
|
|
end
|
|
|
|
local function command_list()
|
|
local cmds = {}
|
|
for i, command in ipairs(get_commands()) do
|
|
cmds[i] = command.name
|
|
end
|
|
|
|
return cmds
|
|
end
|
|
|
|
local function property_list()
|
|
local properties = mp.get_property_native("property-list")
|
|
|
|
for _, sub_property in pairs({"video", "audio", "sub", "sub2"}) do
|
|
properties[#properties + 1] = "current-tracks/" .. sub_property
|
|
end
|
|
|
|
for _, sub_property in pairs({"text", "text-primary"}) do
|
|
properties[#properties + 1] = "clipboard/" .. sub_property
|
|
end
|
|
|
|
return properties
|
|
end
|
|
|
|
local function profile_list()
|
|
local profiles = {}
|
|
|
|
for i, profile in ipairs(mp.get_property_native("profile-list")) do
|
|
profiles[i] = profile.name
|
|
end
|
|
|
|
return profiles
|
|
end
|
|
|
|
local function list_option_list()
|
|
local opts = {}
|
|
|
|
-- Don"t log errors for renamed and removed properties.
|
|
-- (Just mp.enable_messages("fatal") still logs them to the terminal.)
|
|
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 _, option in pairs(mp.get_property_native("options")) do
|
|
if mp.get_property("option-info/" .. option .. "/type", ""):find(" list$") then
|
|
opts[#opts + 1] = option
|
|
end
|
|
end
|
|
|
|
mp.set_property("msg-level", msg_level_backup)
|
|
|
|
return opts
|
|
end
|
|
|
|
local function list_option_action_list(option)
|
|
local type = mp.get_property("option-info/" .. option .. "/type")
|
|
|
|
if type == "Key/value list" then
|
|
return {"add", "append", "set", "remove"}
|
|
end
|
|
|
|
if type == "String list" or type == "Object settings list" then
|
|
return {"add", "append", "clr", "pre", "set", "remove", "toggle"}
|
|
end
|
|
end
|
|
|
|
local function list_option_value_list(option)
|
|
local values = mp.get_property_native(option)
|
|
|
|
if type(values) ~= "table" then
|
|
return
|
|
end
|
|
|
|
if type(values[1]) ~= "table" then
|
|
return values
|
|
end
|
|
|
|
for i, value in ipairs(values) do
|
|
values[i] = value.label and "@" .. value.label or value.name
|
|
end
|
|
|
|
return values
|
|
end
|
|
|
|
local function has_file_argument(candidate_command)
|
|
for _, command in pairs(get_commands()) do
|
|
if command.name == candidate_command then
|
|
return command.args[1] and
|
|
(command.args[1].name == "filename" or command.args[1].name == "url")
|
|
end
|
|
end
|
|
end
|
|
|
|
local function file_list(directory)
|
|
if directory == "" then
|
|
directory = "."
|
|
else
|
|
directory = mp.command_native({"expand-path", directory})
|
|
end
|
|
|
|
local files = utils.readdir(directory, "files") or {}
|
|
|
|
for _, dir in pairs(utils.readdir(directory, "dirs") or {}) do
|
|
files[#files + 1] = dir .. path_separator
|
|
end
|
|
|
|
return files
|
|
end
|
|
|
|
local function handle_file_completion(before_cur)
|
|
local directory, last_component_pos =
|
|
before_cur:sub(completion_pos):match("(.-)()[^" .. path_separator .."]*$")
|
|
|
|
completion_pos = completion_pos + last_component_pos - 1
|
|
|
|
-- Don"t use completion_append for file completion to not add quotes after
|
|
-- directories whose entries you may want to complete afterwards.
|
|
completion_append = ""
|
|
|
|
return file_list(directory)
|
|
end
|
|
|
|
local function handle_choice_completion(option, before_cur)
|
|
local info = mp.get_property_native("option-info/" .. option, {})
|
|
|
|
if info.type == "Flag" then
|
|
return { "no", "yes" }, before_cur
|
|
end
|
|
|
|
if info["expects-file"] then
|
|
return handle_file_completion(before_cur)
|
|
end
|
|
|
|
-- Fix completing the empty value for --dscale and --cscale.
|
|
if info.choices and info.choices[1] == "" and completion_append == "" then
|
|
info.choices[1] = '""'
|
|
end
|
|
|
|
return info.choices
|
|
end
|
|
|
|
local function command_flags_at_1st_argument_list(command)
|
|
local flags = {
|
|
["playlist-next"] = {"weak", "force"},
|
|
["playlist-play-index"] = {"current", "none"},
|
|
["playlist-remove"] = {"current"},
|
|
["rescan-external-files"] = {"reselect", "keep-selection"},
|
|
["revert-seek"] = {"mark", "mark-permanent"},
|
|
["screenshot"] = {"subtitles", "video", "window", "each-frame"},
|
|
["stop"] = {"keep-playlist"},
|
|
}
|
|
flags["playlist-prev"] = flags["playlist-next"]
|
|
flags["screenshot-raw"] = flags.screenshot
|
|
|
|
return flags[command]
|
|
end
|
|
|
|
local function command_flags_at_2nd_argument_list(command)
|
|
local flags = {
|
|
["apply-profile"] = {"default", "restore"},
|
|
["frame-step"] = {"play", "seek", "mute"},
|
|
["loadfile"] = {"replace", "append", "append-play", "insert-next",
|
|
"insert-next-play", "insert-at", "insert-at-play"},
|
|
["screenshot-to-file"] = {"subtitles", "video", "window", "each-frame"},
|
|
["screenshot-raw"] = {"bgr0", "bgra", "rgba", "rgba64"},
|
|
["seek"] = {"relative", "absolute", "absolute-percent",
|
|
"relative-percent", "keyframes", "exact"},
|
|
["sub-add"] = {"select", "auto", "cached"},
|
|
["sub-seek"] = {"primary", "secondary"},
|
|
}
|
|
flags.loadlist = flags.loadfile
|
|
flags["audio-add"] = flags["sub-add"]
|
|
flags["video-add"] = flags["sub-add"]
|
|
flags["sub-step"] = flags["sub-seek"]
|
|
|
|
return flags[command]
|
|
end
|
|
|
|
local function handle_flags(command, arg_index, flags)
|
|
for _, cmd in pairs(get_commands()) do
|
|
if cmd.name == command then
|
|
if cmd.args[arg_index] and cmd.args[arg_index].type == "Flags" then
|
|
break
|
|
else
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
local plus_pos = flags:find("%+[^%+]*$")
|
|
|
|
if plus_pos then
|
|
completion_pos = completion_pos + plus_pos
|
|
end
|
|
end
|
|
|
|
local function executable_list()
|
|
local executable_map = {}
|
|
local path = os.getenv("PATH") or ""
|
|
local separator = platform == "windows" and ";" or ":"
|
|
local exts = {}
|
|
|
|
for ext in (os.getenv("PATHEXT") or ""):gmatch("[^;]+") do
|
|
exts[ext:lower()] = true
|
|
end
|
|
|
|
for directory in path:gmatch("[^" .. separator .. "]+") do
|
|
for _, executable in pairs(utils.readdir(directory, "files") or {}) do
|
|
if not next(exts) or exts[(executable:match("%.%w+$") or ""):lower()] then
|
|
executable_map[executable] = true
|
|
end
|
|
end
|
|
end
|
|
|
|
local executables = {}
|
|
for executable, _ in pairs(executable_map) do
|
|
executables[#executables + 1] = executable
|
|
end
|
|
|
|
return executables
|
|
end
|
|
|
|
local function filter_label_list(type)
|
|
local values = {"all"}
|
|
|
|
for _, value in pairs(mp.get_property_native(type)) do
|
|
if value.label then
|
|
values[#values + 1] = value.label
|
|
end
|
|
end
|
|
|
|
return values
|
|
end
|
|
|
|
local function complete(before_cur)
|
|
local tokens = {}
|
|
local first_useful_token_index = 1
|
|
local completions
|
|
|
|
local begin_new_token = true
|
|
local last_quote
|
|
for pos, char in before_cur:gmatch("()(.)") do
|
|
if char:find("[%s;]") and not last_quote then
|
|
begin_new_token = true
|
|
if char == ";" then
|
|
first_useful_token_index = #tokens + 1
|
|
end
|
|
elseif begin_new_token then
|
|
tokens[#tokens + 1] = { text = char, pos = pos }
|
|
last_quote = char:match("['\"]")
|
|
begin_new_token = false
|
|
else
|
|
tokens[#tokens].text = tokens[#tokens].text .. char
|
|
if char == last_quote then
|
|
last_quote = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
completion_append = last_quote or ""
|
|
|
|
-- Strip quotes from tokens.
|
|
for _, token in pairs(tokens) do
|
|
if token.text:find('^"') then
|
|
token.text = token.text:sub(2):gsub('"$', "")
|
|
token.pos = token.pos + 1
|
|
elseif token.text:find("^'") then
|
|
token.text = token.text:sub(2):gsub('"$', "")
|
|
token.pos = token.pos + 1
|
|
end
|
|
end
|
|
|
|
-- Skip command prefixes because it is not worth lumping them together with
|
|
-- command completions when they are useless for interactive usage.
|
|
local command_prefixes = {
|
|
["osd-auto"] = true, ["no-osd"] = true, ["osd-bar"] = true,
|
|
["osd-msg"] = true, ["osd-msg-bar"] = true, ["raw"] = true,
|
|
["expand-properties"] = true, ["repeatable"] = true,
|
|
["nonrepeatable"] = true, ["nonscalable"] = true,
|
|
["async"] = true, ["sync"] = true
|
|
}
|
|
|
|
-- Add an empty token if the cursor is after whitespace or ; to simplify
|
|
-- comparisons.
|
|
if before_cur == "" or before_cur:find("[%s;]$") then
|
|
tokens[#tokens + 1] = { text = "", pos = #before_cur + 1 }
|
|
end
|
|
|
|
while tokens[first_useful_token_index] and
|
|
command_prefixes[tokens[first_useful_token_index].text] do
|
|
if first_useful_token_index == #tokens then
|
|
return
|
|
end
|
|
|
|
first_useful_token_index = first_useful_token_index + 1
|
|
end
|
|
|
|
completion_pos = tokens[#tokens].pos
|
|
|
|
local add_actions = {
|
|
["add"] = true, ["append"] = true, ["pre"] = true, ["set"] = true
|
|
}
|
|
|
|
local first_useful_token = tokens[first_useful_token_index]
|
|
|
|
local property_pos = before_cur:match("${[=>]?()[%w_/-]*$")
|
|
if property_pos then
|
|
completion_pos = property_pos
|
|
completions = property_list()
|
|
completion_append = "}"
|
|
elseif #tokens == first_useful_token_index then
|
|
completions = command_list()
|
|
completions[#completions + 1] = "help"
|
|
elseif #tokens == first_useful_token_index + 1 then
|
|
if first_useful_token.text == "set" or
|
|
first_useful_token.text == "add" or
|
|
first_useful_token.text == "cycle" or
|
|
first_useful_token.text == "cycle-values" or
|
|
first_useful_token.text == "multiply" then
|
|
completions = property_list()
|
|
elseif first_useful_token.text == "help" then
|
|
completions = command_list()
|
|
elseif first_useful_token.text == "apply-profile" then
|
|
completions = profile_list()
|
|
elseif first_useful_token.text == "change-list" then
|
|
completions = list_option_list()
|
|
elseif first_useful_token.text == "run" then
|
|
completions = executable_list()
|
|
elseif first_useful_token.text == "vf" or
|
|
first_useful_token.text == "af" then
|
|
completions = list_option_action_list(first_useful_token.text)
|
|
elseif first_useful_token.text == "vf-command" or
|
|
first_useful_token.text == "af-command" then
|
|
completions = filter_label_list(first_useful_token.text:sub(1,2))
|
|
elseif has_file_argument(first_useful_token.text) then
|
|
completions = handle_file_completion(before_cur)
|
|
else
|
|
completions = command_flags_at_1st_argument_list(first_useful_token.text)
|
|
handle_flags(first_useful_token.text, 1, tokens[#tokens].text)
|
|
end
|
|
elseif first_useful_token.text == "cycle-values" then
|
|
completions = handle_choice_completion(tokens[first_useful_token_index + 1].text,
|
|
before_cur)
|
|
elseif #tokens == first_useful_token_index + 2 then
|
|
if first_useful_token.text == "set" then
|
|
completions = handle_choice_completion(tokens[first_useful_token_index + 1].text,
|
|
before_cur)
|
|
elseif first_useful_token.text == "change-list" then
|
|
completions = list_option_action_list(tokens[first_useful_token_index + 1].text)
|
|
elseif first_useful_token.text == "vf" or
|
|
first_useful_token.text == "af" then
|
|
if add_actions[tokens[first_useful_token_index + 1].text] then
|
|
completions = handle_choice_completion(first_useful_token.text, before_cur)
|
|
elseif tokens[first_useful_token_index + 1].text == "remove" then
|
|
completions = list_option_value_list(first_useful_token.text)
|
|
end
|
|
else
|
|
completions = command_flags_at_2nd_argument_list(first_useful_token.text)
|
|
handle_flags(first_useful_token.text, 2, tokens[#tokens].text)
|
|
end
|
|
elseif #tokens == first_useful_token_index + 3 then
|
|
if first_useful_token.text == "change-list" then
|
|
if add_actions[tokens[first_useful_token_index + 2].text] then
|
|
completions = handle_choice_completion(tokens[first_useful_token_index + 1].text,
|
|
before_cur)
|
|
elseif tokens[first_useful_token_index + 2].text == "remove" then
|
|
completions = list_option_value_list(tokens[first_useful_token_index + 1].text)
|
|
end
|
|
elseif first_useful_token.text == "dump-cache" then
|
|
completions = handle_file_completion(before_cur)
|
|
end
|
|
end
|
|
|
|
return completions or {}, completion_pos, completion_append
|
|
end
|
|
|
|
local function open(text, cursor_position, keep_open)
|
|
input.get({
|
|
prompt = ">",
|
|
submit = submit,
|
|
keep_open = keep_open,
|
|
opened = opened,
|
|
closed = closed,
|
|
complete = complete,
|
|
autoselect_completion = true,
|
|
default_text = text,
|
|
cursor_position = cursor_position,
|
|
history_path = options.persist_history
|
|
and mp.command_native({"expand-path", options.history_path}) or nil,
|
|
})
|
|
end
|
|
|
|
mp.add_key_binding(nil, "open", function ()
|
|
open(last_text, last_cursor_position, true)
|
|
end)
|
|
|
|
-- Open the console with passed text and cursor_position arguments
|
|
mp.register_script_message("type", open)
|
|
|
|
mp.register_event("log-message", function(e)
|
|
-- Ignore log messages from the OSD because of paranoia, since writing them
|
|
-- to the OSD could generate more messages in an infinite loop.
|
|
if e.prefix:sub(1, 3) == "osd" then return end
|
|
|
|
-- Ignore messages output by this script.
|
|
if e.prefix == mp.get_script_name() then return end
|
|
|
|
-- Ignore buffer overflow warning messages. Overflowed log messages would
|
|
-- have been offscreen anyway.
|
|
if e.prefix == "overflow" then return end
|
|
|
|
-- Filter out trace-level log messages, even if the terminal-default log
|
|
-- level includes them. These aren"t too useful for an on-screen display
|
|
-- without scrollback and they include messages that are generated from the
|
|
-- OSD display itself.
|
|
if e.level == "trace" then return end
|
|
|
|
-- Use color for debug/v/warn/error/fatal messages.
|
|
input.log("[" .. e.prefix .. "] " .. e.text:sub(1, -2), styles[e.level],
|
|
terminal_styles[e.level])
|
|
end)
|
|
|
|
-- Enable log messages. In silent mode, mpv will queue log messages in a buffer
|
|
-- until enable_messages is called again without the silent: prefix.
|
|
mp.enable_messages("silent:terminal-default")
|
|
|
|
require "mp.options".read_options(options)
|