This commit is contained in:
2026-03-27 07:06:16 +01:00
commit 1541961403
340 changed files with 151916 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
local mp = require 'mp'
local msg = require 'mp.msg'
local utils = require 'mp.utils'
local o = require 'modules.options'
local g = require 'modules.globals'
local fb_utils = require 'modules.utils'
local parser_API = require 'modules.apis.parser'
local API_MAJOR, API_MINOR, API_PATCH = g.API_VERSION:match("(%d+)%.(%d+)%.(%d+)")
API_MAJOR, API_MINOR, API_PATCH = tonumber(API_MAJOR), tonumber(API_MINOR), tonumber(API_PATCH)
---checks if the given parser has a valid version number
---@param parser Parser|Keybind
---@param id string
---@return boolean?
local function check_api_version(parser, id)
if parser.version then
msg.warn(('%s: use of the `version` field is deprecated - use `api_version` instead'):format(id))
parser.api_version = parser.version
end
local version = parser.api_version
if type(version) ~= 'string' then return msg.error(("%s: field `api_version` must be a string, got %s"):format(id, tostring(version))) end
local major, minor = version:match("(%d+)%.(%d+)")
major, minor = tonumber(major), tonumber(minor)
if not major or not minor then
return msg.error(("%s: invalid version number, expected v%d.%d.x, got v%s"):format(id, API_MAJOR, API_MINOR, version))
elseif major ~= API_MAJOR then
return msg.error(("%s has wrong major version number, expected v%d.x.x, got, v%s"):format(id, API_MAJOR, version))
elseif minor > API_MINOR then
msg.warn(("%s has newer minor version number than API, expected v%d.%d.x, got v%s"):format(id, API_MAJOR, API_MINOR, version))
end
return true
end
---create a unique id for the given parser
---@param parser Parser
local function set_parser_id(parser)
local name = parser.name
if g.parsers[name] then
local n = 2
name = parser.name.."_"..n
while g.parsers[name] do
n = n + 1
name = parser.name.."_"..n
end
end
g.parsers[name] = parser
g.parsers[parser] = { id = name }
end
---runs an addon in a separate environment
---@param path string
---@return unknown
local function run_addon(path)
local name_sqbr = string.format("[%s]", path:match("/([^/]*)%.lua$"))
local addon_environment = fb_utils.redirect_table(_G)
addon_environment._G = addon_environment ---@diagnostic disable-line inject-field
--gives each addon custom debug messages
addon_environment.package = fb_utils.redirect_table(addon_environment.package) ---@diagnostic disable-line inject-field
addon_environment.package.loaded = fb_utils.redirect_table(addon_environment.package.loaded)
local msg_module = {
log = function(level, ...) msg.log(level, name_sqbr, ...) end,
fatal = function(...) return msg.fatal(name_sqbr, ...) end,
error = function(...) return msg.error(name_sqbr, ...) end,
warn = function(...) return msg.warn(name_sqbr, ...) end,
info = function(...) return msg.info(name_sqbr, ...) end,
verbose = function(...) return msg.verbose(name_sqbr, ...) end,
debug = function(...) return msg.debug(name_sqbr, ...) end,
trace = function(...) return msg.trace(name_sqbr, ...) end,
}
addon_environment.print = msg_module.info ---@diagnostic disable-line inject-field
addon_environment.require = function(module) ---@diagnostic disable-line inject-field
if module == "mp.msg" then return msg_module end
return require(module)
end
---@type function?, string?
local chunk, err
if setfenv then ---@diagnostic disable-line deprecated
--since I stupidly named a function loadfile I need to specify the global one
--I've been using the name too long to want to change it now
chunk, err = _G.loadfile(path)
if not chunk then return msg.error(err) end
setfenv(chunk, addon_environment) ---@diagnostic disable-line deprecated
else
chunk, err = _G.loadfile(path, "bt", addon_environment) ---@diagnostic disable-line redundant-parameter
if not chunk then return msg.error(err) end
end
---@diagnostic disable-next-line no-unknown
local success, result = xpcall(chunk, fb_utils.traceback)
return success and result or nil
end
---Setup an internal or external parser.
---Note that we're somewhat bypassing the type system here as we're converting from a
---ParserConfig object to a Parser object. As such we need to make sure that the
---we're doing everything correctly. A 2.0 release of the addon API could simplify
---this by formally separating ParserConfigs from Parsers and providing an
---API to register parsers.
---@param parser ParserConfig
---@param file string
---@return nil
local function setup_parser(parser, file)
parser = setmetatable(parser, { __index = parser_API }) --[[@as Parser]]
parser.name = parser.name or file:gsub("%-browser%.lua$", ""):gsub("%.lua$", "")
set_parser_id(parser)
if not check_api_version(parser, file) then return msg.error("aborting load of parser", parser:get_id(), "from", file) end
msg.verbose("imported parser", parser:get_id(), "from", file)
--sets missing functions
if not parser.can_parse then
if parser.parse then parser.can_parse = function() return true end
else parser.can_parse = function() return false end end
end
if parser.priority == nil then parser.priority = 0 end
if type(parser.priority) ~= "number" then return msg.error("parser", parser:get_id(), "needs a numeric priority") end
table.insert(g.parsers, parser)
end
---load an external addon
---@param file string
---@param path string
---@return nil
local function setup_addon(file, path)
if file:sub(-4) ~= ".lua" then return msg.verbose(path, "is not a lua file - aborting addon setup") end
local addon_parsers = run_addon(path) --[=[@as ParserConfig|ParserConfig[]]=]
if addon_parsers and not next(addon_parsers) then return msg.verbose('addon', path, 'returned empry table - special case, ignoring') end
if not addon_parsers or type(addon_parsers) ~= "table" then return msg.error("addon", path, "did not return a table") end
--if the table contains a priority key then we assume it isn't an array of parsers
if not addon_parsers[1] then addon_parsers = {addon_parsers} end
for _, parser in ipairs(addon_parsers --[=[@as ParserConfig[]]=]) do
setup_parser(parser, file)
end
end
---loading external addons
---@param directory string
---@return nil
local function load_addons(directory)
directory = fb_utils.fix_path(directory, true)
local files = utils.readdir(directory)
if not files then return msg.verbose('not loading external addons - could not read', o.addon_directory) end
for _, file in ipairs(files) do
setup_addon(file, directory..file)
end
end
local function load_internal_addons()
local script_dir = mp.get_script_directory()
if not script_dir then return msg.error('script is not being run as a directory script!') end
local internal_addon_dir = script_dir..'/modules/addons/'
load_addons(internal_addon_dir)
end
local function load_external_addons()
local addon_dir = mp.command_native({"expand-path", o.addon_directory..'/'}) --[[@as string|nil]]
if not addon_dir then return msg.verbose('not loading external addons - could not resolve', o.addon_directory) end
load_addons(addon_dir)
end
---Orders the addons by priority, sets the parser index values,
---and runs the setup methods of the addons.
local function setup_addons()
table.sort(g.parsers, function(a, b) return a.priority < b.priority end)
--we want to store the indexes of the parsers
for i = #g.parsers, 1, -1 do g.parsers[ g.parsers[i] ].index = i end
--we want to run the setup functions for each addon
for index, parser in ipairs(g.parsers) do
if parser.setup then
local success = xpcall(function() parser:setup() end, fb_utils.traceback)
if not success then
msg.error("parser", parser:get_id(), "threw an error in the setup method - removing from list of parsers")
table.remove(g.parsers, index)
end
end
end
end
---@class addons
return {
check_api_version = check_api_version,
load_internal_addons = load_internal_addons,
load_external_addons = load_external_addons,
setup_addons = setup_addons,
}
@@ -0,0 +1,146 @@
--[[
This file is an internal file-browser addon.
It should not be imported like a normal module.
Maintains a cache of the accessed directories to improve
parsing speed. Disabled by default.
]]
local mp = require 'mp'
local msg = require 'mp.msg'
local utils = require 'mp.utils'
local fb = require 'file-browser'
---@type ParserConfig
local cacheParser = {
name = 'cache',
priority = 0,
api_version = '1.9',
}
---@class CacheEntry
---@field list List
---@field opts Opts?
---@field timeout MPTimer
---@type table<string,CacheEntry>
local cache = {}
---@type table<string,(async fun(list: List?, opts: Opts?))[]>
local pending_parses = {}
---@param directories? string[]
local function clear_cache(directories)
if directories then
msg.debug('clearing cache for', #directories, 'directorie(s)')
for _, dir in ipairs(directories) do
if cache[dir] then
msg.trace('clearing cache for', dir)
cache[dir].timeout:kill()
cache[dir] = nil
end
end
else
msg.debug('clearing cache')
for _, entry in pairs(cache) do
entry.timeout:kill()
end
cache = {}
end
end
---@type string
local prev_directory = ''
function cacheParser:can_parse(directory, parse_state)
-- allows the cache to be forcibly used or bypassed with the
-- cache/use parse property.
if parse_state.properties.cache and parse_state.properties.cache.use ~= nil then
if parse_state.source == 'browser' then prev_directory = directory end
return parse_state.properties.cache.use
end
-- the script message is guaranteed to always bypass the cache
if parse_state.source == 'script-message' then return false end
if not fb.get_opt('cache') or directory == '' then return false end
-- clear the cache if reloading the current directory in the browser
-- this means that fb.rescan() should maintain expected behaviour
if parse_state.source == 'browser' then
if prev_directory == directory then clear_cache({directory}) end
prev_directory = directory
end
return true
end
---@async
function cacheParser:parse(directory)
if cache[directory] then
msg.verbose('fetching', directory, 'contents from cache')
cache[directory].timeout:kill()
cache[directory].timeout:resume()
return cache[directory].list, cache[directory].opts
end
---@type List?, Opts?
local list, opts
-- if another parse is already running on the same directory, then wait and use the same result
if not pending_parses[directory] then
pending_parses[directory] = {}
list, opts = self:defer(directory)
else
msg.debug('parse for', directory, 'already running - waiting for other parse to finish...')
table.insert(pending_parses[directory], fb.coroutine.callback(30))
list, opts = coroutine.yield()
end
local pending = pending_parses[directory]
-- need to clear the pending parses before resuming them or they will also attempt to resume the parses
pending_parses[directory] = nil
if pending and #pending > 0 then
msg.debug('resuming', #pending, 'pending parses for', directory)
for _, cb in ipairs(pending) do
cb(list, opts)
end
end
if not list then return end
-- pending will be truthy for the original parse and falsy for any parses that were pending
if pending then
msg.debug('storing', directory, 'contents in cache')
cache[directory] = {
list = list,
opts = opts,
timeout = mp.add_timeout(120, function() cache[directory] = nil end),
}
end
return list, opts
end
cacheParser.keybinds = {
{
key = 'Ctrl+Shift+r',
name = 'clear',
command = function() clear_cache() ; fb.rescan() end,
}
}
-- provide method of clearing the cache through script messages
mp.register_script_message('cache/clear', function(dirs)
if not dirs then
return clear_cache()
end
---@type string[]?
local directories = utils.parse_json(dirs)
if not directories then msg.error('unable to parse', dirs) end
clear_cache(directories)
end)
return cacheParser
@@ -0,0 +1,46 @@
-- This file is an internal file-browser addon.
-- It should not be imported like a normal module.
local msg = require 'mp.msg'
local utils = require 'mp.utils'
---Parser for native filesystems
---@type ParserConfig
local file_parser = {
name = "file",
priority = 110,
api_version = '1.0.0',
}
--try to parse any directory except for the root
function file_parser:can_parse(directory)
return directory ~= ''
end
--scans the given directory using the mp.utils.readdir function
function file_parser:parse(directory)
local new_list = {}
local list1 = utils.readdir(directory, 'dirs')
if list1 == nil then return nil end
--sorts folders and formats them into the list of directories
for i=1, #list1 do
local item = list1[i]
msg.trace(item..'/')
table.insert(new_list, {name = item..'/', type = 'dir'})
end
--appends files to the list of directory items
local list2 = utils.readdir(directory, 'files')
if list2 == nil then return nil end
for i=1, #list2 do
local item = list2[i]
msg.trace(item)
table.insert(new_list, {name = item, type = 'file'})
end
return new_list
end
return file_parser
@@ -0,0 +1,124 @@
--[[
This file is an internal file-browser addon.
It should not be imported like a normal module.
Allows searching the current directory.
]]--
local msg = require "mp.msg"
local fb = require "file-browser"
local input_loaded, input = pcall(require, "mp.input")
local user_input_loaded, user_input = pcall(require, "user-input-module")
---@type ParserConfig
local find = {
api_version = "1.3.0"
}
---@type thread|nil
local latest_coroutine = nil
---@type State
local global_fb_state = getmetatable(fb.get_state()).__original
---@param name string
---@param query string
---@return boolean
local function compare(name, query)
if name:find(query) then return true end
if name:lower():find(query) then return true end
if name:upper():find(query) then return true end
return false
end
---@async
---@param key Keybind
---@param state State
---@param co thread
---@return boolean?
local function main(key, state, co)
if not state.list then return false end
---@type string
local text
if key.name == "find/find" then text = "Find: enter search string"
else text = "Find: enter advanced search string" end
if input_loaded then
input.get({
prompt = text .. "\n>",
id = "file-browser/find",
submit = fb.coroutine.callback(),
})
elseif user_input_loaded then
user_input.get_user_input( fb.coroutine.callback(), { text = text, id = "find", replace = true } )
end
local query, error = coroutine.yield()
if input_loaded then input.terminate() end
if not query then return msg.debug(error) end
-- allow the directory to be changed before this point
local list = fb.get_list()
local parse_id = global_fb_state.co
if key.name == "find/find" then
query = fb.pattern_escape(query)
end
local results = {}
for index, item in ipairs(list) do
if compare(item.label or item.name, query) then
table.insert(results, index)
end
end
if (#results < 1) then
msg.warn("No matching items for '"..query.."'")
return
end
--keep cycling through the search results if any are found
--putting this into a separate coroutine removes any passthrough ambiguity
--the final return statement should return to `step_find` not any other function
---@async
fb.coroutine.run(function()
latest_coroutine = coroutine.running()
---@type number
local rindex = 1
while (true) do
if rindex == 0 then rindex = #results
elseif rindex == #results + 1 then rindex = 1 end
fb.set_selected_index(results[rindex])
local direction = coroutine.yield(true) --[[@as number]]
rindex = rindex + direction
if parse_id ~= global_fb_state.co then
latest_coroutine = nil
return
end
end
end)
end
local function step_find(key)
if not latest_coroutine then return false end
---@type number
local direction = 0
if key.name == "find/next" then direction = 1
elseif key.name == "find/prev" then direction = -1 end
return fb.coroutine.resume_err(latest_coroutine, direction)
end
find.keybinds = {
{"Ctrl+f", "find", main, {}},
{"Ctrl+F", "find_advanced", main, {}},
{"n", "next", step_find, {}},
{"N", "prev", step_find, {}},
}
return find
@@ -0,0 +1,31 @@
--[[
An addon for mpv-file-browser which displays ~/ for the home directory instead of the full path
]]--
local mp = require "mp"
local fb = require "file-browser"
local home = fb.fix_path(mp.command_native({"expand-path", "~/"}) --[[@as string]], true)
---@type ParserConfig
local home_label = {
priority = 100,
api_version = "1.0.0"
}
function home_label:can_parse(directory)
if not fb.get_opt('home_label') then return false end
return directory:sub(1, home:len()) == home
end
---@async
function home_label:parse(directory)
local list, opts = self:defer(directory)
if not opts then opts = {} end
if (not opts.directory or opts.directory == directory) and not opts.directory_label then
opts.directory_label = "~/"..(directory:sub(home:len()+1) or "")
end
return list, opts
end
return home_label
@@ -0,0 +1,62 @@
--[[
An addon for mpv-file-browser which stores the last opened directory and
sets it as the opened directory the next time mpv is opened.
Available at: https://github.com/CogentRedTester/mpv-file-browser/tree/master/addons
]]--
local mp = require 'mp'
local msg = require 'mp.msg'
local fb = require 'file-browser'
local state_file = mp.command_native({'expand-path', fb.get_opt('last_opened_directory_file')}) --[[@as string]]
msg.verbose('using', state_file)
---@param directory? string
---@return nil
local function write_directory(directory)
if not fb.get_opt('save_last_opened_directory') then return end
local file = io.open(state_file, 'w+')
if not file then return msg.error('could not open', state_file, 'for writing') end
directory = directory or fb.get_directory() or ''
msg.verbose('writing', directory, 'to', state_file)
file:write(directory)
file:close()
end
---@type ParserConfig
local addon = {
api_version = '1.7.0',
priority = 0,
}
function addon:setup()
if not fb.get_opt('default_to_last_opened_directory') then return end
local file = io.open(state_file, "r")
if not file then
return msg.info('failed to open', state_file, 'for reading (may be due to first load)')
end
local dir = file:read("*a")
msg.verbose('setting default directory to', dir)
fb.browse_directory(dir, false)
file:close()
end
function addon:can_parse(dir, parse_state)
if parse_state.source == 'browser' then write_directory(dir) end
return false
end
function addon:parse()
return nil
end
mp.register_event('shutdown', function() write_directory() end)
return addon
@@ -0,0 +1,68 @@
--[[
An addon for mpv-file-browser which uses the Linux ls command to parse native directories
This behaves near identically to the native parser, but IO is done asynchronously.
Available at: https://github.com/CogentRedTester/mpv-file-browser/tree/master/addons
]]--
local mp = require "mp"
local msg = require "mp.msg"
local fb = require "file-browser"
local PLATFORM = fb.get_platform()
---@type ParserConfig
local ls = {
priority = 109,
api_version = "1.9.0",
name = "ls",
keybind_name = "file"
}
---@async
---@param args string[]
---@param parse_state ParseState
---@return string|nil
local function command(args, parse_state)
local async = mp.command_native_async({
name = "subprocess",
playback_only = false,
capture_stdout = true,
capture_stderr = true,
args = args
}, fb.coroutine.callback(30))
---@type boolean, boolean, MPVSubprocessResult
local completed, _, cmd = parse_state:yield()
if not completed then
msg.warn('read timed out for:', table.unpack(args))
mp.abort_async_command(async)
return nil
end
return cmd.status == 0 and cmd.stdout or nil
end
function ls:can_parse(directory)
if not fb.get_opt('ls_parser') then return false end
return PLATFORM ~= 'windows' and directory ~= '' and not fb.get_protocol(directory)
end
---@async
function ls:parse(directory, parse_state)
local list = {}
local files = command({"ls", "-1", "-p", "-A", "-N", "--zero", "-L", directory}, parse_state)
if not files then return nil end
for str in files:gmatch("%Z+") do
local is_dir = str:sub(-1) == "/"
msg.trace(str)
table.insert(list, {name = str, type = is_dir and "dir" or "file"})
end
return list
end
return ls
@@ -0,0 +1,26 @@
-- This file is an internal file-browser addon.
-- It should not be imported like a normal module.
local g = require 'modules.globals'
---Parser for the root.
---@type ParserConfig
local root_parser = {
name = "root",
priority = math.huge,
api_version = '1.0.0',
}
function root_parser:can_parse(directory)
return directory == ''
end
--we return the root directory exactly as setup
function root_parser:parse()
return g.root, {
sorted = true,
filtered = true,
}
end
return root_parser
@@ -0,0 +1,218 @@
--[[
An addon for mpv-file-browser which uses the Windows dir command to parse native directories
This behaves near identically to the native parser, but IO is done asynchronously.
Available at: https://github.com/CogentRedTester/mpv-file-browser/tree/master/addons
]]--
local mp = require "mp"
local msg = require "mp.msg"
local fb = require "file-browser"
local PLATFORM = fb.get_platform()
---@param bytes string
---@return fun(): number, number
local function byte_iterator(bytes)
---@async
---@return number?
local function iter()
for i = 1, #bytes do
coroutine.yield(bytes:byte(i), i)
end
error('malformed utf16le string - expected byte but found end of string')
end
return coroutine.wrap(iter)
end
---@param bits number
---@param by number
---@return number
local function lshift(bits, by)
return bits * 2^by
end
---@param bits number
---@param by number
---@return integer
local function rshift(bits, by)
return math.floor(bits / 2^by)
end
---@param bits number
---@param i number
---@return number
local function bits_below(bits, i)
return bits % 2^i
end
---@param bits number
---@param i number exclusive
---@param j number inclusive
---@return integer
local function bits_between(bits, i, j)
return rshift(bits_below(bits, j), i)
end
---@param bytes string
---@return number[]
local function utf16le_to_unicode(bytes)
msg.trace('converting from utf16-le to unicode codepoints')
---@type number[]
local codepoints = {}
local get_byte = byte_iterator(bytes)
while true do
-- start of a char
local success, little, i = pcall(get_byte)
if not success then break end
local big = get_byte()
local codepoint = little + lshift(big, 8)
if codepoint < 0xd800 or codepoint > 0xdfff then
table.insert(codepoints, codepoint)
else
-- handling surrogate pairs
-- grab the next two bytes to grab the low surrogate
local high_pair = codepoint
local low_pair = get_byte() + lshift(get_byte(), 8)
if high_pair >= 0xdc00 then
error(('malformed utf16le string at byte #%d (0x%04X) - high surrogate pair should be < 0xDC00'):format(i, high_pair))
elseif low_pair < 0xdc00 then
error(('malformed utf16le string at byte #%d (0x%04X) - low surrogate pair should be >= 0xDC00'):format(i+2, low_pair))
end
-- The last 10 bits of each surrogate are the two halves of the codepoint
-- https://en.wikipedia.org/wiki/UTF-16#Code_points_from_U+010000_to_U+10FFFF
local high_bits = bits_below(high_pair, 10)
local low_bits = bits_below(low_pair, 10)
local surrogate_par = (low_bits + lshift(high_bits, 10)) + 0x10000
table.insert(codepoints, surrogate_par)
end
end
return codepoints
end
---@param codepoints number[]
---@return string
local function unicode_to_utf8(codepoints)
---@type number[]
local bytes = {}
-- https://en.wikipedia.org/wiki/UTF-8#Description
for i, codepoint in ipairs(codepoints) do
if codepoint >= 0xd800 and codepoint <= 0xdfff then
error(('codepoint %d (U+%05X) is within the reserved surrogate pair range (U+D800-U+DFFF)'):format(i, codepoint))
elseif codepoint <= 0x7f then
table.insert(bytes, codepoint)
elseif codepoint <= 0x7ff then
table.insert(bytes, 0xC0 + rshift(codepoint, 6))
table.insert(bytes, 0x80 + bits_below(codepoint, 6))
elseif codepoint <= 0xffff then
table.insert(bytes, 0xE0 + rshift(codepoint, 12))
table.insert(bytes, 0x80 + bits_between(codepoint, 6, 12))
table.insert(bytes, 0x80 + bits_below(codepoint, 6))
elseif codepoint <= 0x10ffff then
table.insert(bytes, 0xF0 + rshift(codepoint, 18))
table.insert(bytes, 0x80 + bits_between(codepoint, 12, 18))
table.insert(bytes, 0x80 + bits_between(codepoint, 6, 12))
table.insert(bytes, 0x80 + bits_below(codepoint, 6))
else
error(('codepoint %d (U+%05X) is larger than U+10FFFF'):format(i, codepoint))
end
end
return string.char(table.unpack(bytes))
end
local function utf8(text)
return unicode_to_utf8(utf16le_to_unicode(text))
end
---@type ParserConfig
local dir = {
priority = 109,
api_version = "1.9.0",
name = "cmd-dir",
keybind_name = "file"
}
---@async
---@param args string[]
---@param parse_state ParseState
---@return string|nil
local function command(args, parse_state)
local async = mp.command_native_async({
name = "subprocess",
playback_only = false,
capture_stdout = true,
capture_stderr = true,
args = args,
}, fb.coroutine.callback(30) )
---@type boolean, boolean, MPVSubprocessResult
local completed, _, cmd = parse_state:yield()
if not completed then
msg.warn('read timed out for:', table.unpack(args))
mp.abort_async_command(async)
return nil
end
local success = xpcall(function()
cmd.stdout = utf8(cmd.stdout) or ''
cmd.stderr = utf8(cmd.stderr) or ''
end, fb.traceback)
if not success then return msg.error('failed to convert utf16-le string to utf8') end
--dir returns this exact error message if the directory is empty
if cmd.status == 1 and cmd.stderr == "File Not Found\r\n" then cmd.status = 0 end
if cmd.status ~= 0 then return msg.error(cmd.stderr) end
return cmd.status == 0 and cmd.stdout or nil
end
function dir:can_parse(directory)
if not fb.get_opt('windir_parser') then return false end
return PLATFORM == 'windows' and directory ~= '' and not fb.get_protocol(directory)
end
---@async
function dir:parse(directory, parse_state)
local list = {}
-- the dir command expects backslashes for our paths
directory = string.gsub(directory, "/", "\\")
local dirs = command({ "cmd", "/U", "/c", "dir", "/b", "/ad", directory }, parse_state)
if not dirs then return end
local files = command({ "cmd", "/U", "/c", "dir", "/b", "/a-d", directory }, parse_state)
if not files then return end
for name in dirs:gmatch("[^\n\r]+") do
name = name.."/"
if fb.valid_dir(name) then
table.insert(list, { name = name, type = "dir" })
msg.trace(name)
end
end
for name in files:gmatch("[^\n\r]+") do
if fb.valid_file(name) then
table.insert(list, { name = name, type = "file" })
msg.trace(name)
end
end
return list, { filtered = true }
end
return dir
@@ -0,0 +1,62 @@
--[[
This file is an internal file-browser addon.
It should not be imported like a normal module.
Automatically populates the root with windows drives on startup.
Ctrl+r will add new drives mounted since startup.
Drives will only be added if they are not already present in the root.
]]
local mp = require 'mp'
local msg = require 'mp.msg'
local fb = require 'file-browser'
local PLATFORM = fb.get_platform()
---returns a list of windows drives
---@return string[]?
local function get_drives()
---@type MPVSubprocessResult?, string?
local result, err = mp.command_native({
name = 'subprocess',
playback_only = false,
capture_stdout = true,
args = {'fsutil', 'fsinfo', 'drives'}
})
if not result then return msg.error(err) end
if result.status ~= 0 then return msg.error('could not read windows root') end
local root = {}
for drive in result.stdout:gmatch("(%a:)\\") do
table.insert(root, drive..'/')
end
return root
end
-- adds windows drives to the root if they are not already present
local function import_drives()
if fb.get_opt('auto_detect_windows_drives') and PLATFORM ~= 'windows' then return end
local drives = get_drives()
if not drives then return end
for _, drive in ipairs(drives) do
fb.register_root_item(drive)
end
end
local keybind = {
key = 'Ctrl+r',
name = 'import_root_drives',
command = import_drives,
parser = 'root',
passthrough = true
}
---@type ParserConfig
return {
api_version = '1.9.0',
setup = import_drives,
keybinds = { keybind }
}
+198
View File
@@ -0,0 +1,198 @@
local mp = require 'mp'
local msg = require 'mp.msg'
local utils = require 'mp.utils'
local o = require 'modules.options'
local g = require 'modules.globals'
local fb_utils = require 'modules.utils'
local ass = require 'modules.ass'
local directory_movement = require 'modules.navigation.directory-movement'
local scanning = require 'modules.navigation.scanning'
local controls = require 'modules.controls'
---@class FbAPI: fb_utils
local fb = setmetatable({}, { __index = setmetatable({}, { __index = fb_utils }) })
package.loaded["file-browser"] = setmetatable({}, { __index = fb })
--these functions we'll provide as-is
fb.redraw = ass.update_ass
fb.browse_directory = controls.browse_directory
---Clears the directory cache.
---@return thread
function fb.rescan()
return scanning.rescan()
end
---@async
---@return thread
function fb.rescan_await()
local co = scanning.rescan(nil, fb_utils.coroutine.callback())
coroutine.yield()
return co
end
---@param directories? string[]
function fb.clear_cache(directories)
if directories then
mp.commandv('script-message-to', mp.get_script_name(), 'cache/clear', utils.format_json(directories))
else
mp.commandv('script-message-to', mp.get_script_name(), 'cache/clear')
end
end
---A wrapper around scan_directory for addon API.
---@async
---@param directory string
---@param parse_state ParseStateTemplate
---@return Item[]|nil
---@return Opts
function fb.parse_directory(directory, parse_state)
if not parse_state then parse_state = { source = "addon" }
elseif not parse_state.source then parse_state.source = "addon" end
return scanning.scan_directory(directory, parse_state)
end
---Register file extensions which can be opened by the browser.
---@param ext string
function fb.register_parseable_extension(ext)
g.parseable_extensions[string.lower(ext)] = true
end
---Deregister file extensions which can be opened by the browser.
---@param ext string
function fb.remove_parseable_extension(ext)
g.parseable_extensions[string.lower(ext)] = nil
end
---Add a compatible extension to show through the filter, only applies if run during the setup() method.
---@param ext string
function fb.add_default_extension(ext)
table.insert(g.compatible_file_extensions, ext)
end
---Add item to root at position pos.
---@param item Item
---@param pos? number
function fb.insert_root_item(item, pos)
msg.debug("adding item to root", item.label or item.name, pos)
item.ass = item.ass or fb.ass_escape(item.label or item.name)
item.type = "dir"
table.insert(g.root, pos or (#g.root + 1), item)
end
---Add a new mapping to the given directory.
---@param directory string
---@param mapping string
---@param pattern? boolean
---@return string
function fb.register_directory_mapping(directory, mapping, pattern)
if not pattern then mapping = '^'..fb_utils.pattern_escape(mapping) end
g.directory_mappings[mapping] = directory
msg.verbose('registering directory alias', mapping, directory)
directory_movement.set_current_file(g.current_file.original_path)
return mapping
end
---Remove all directory mappings that map to the given directory.
---@param directory string
---@return string[]
function fb.remove_all_mappings(directory)
local removed = {}
for mapping, target in pairs(g.directory_mappings) do
if target == directory then
g.directory_mappings[mapping] = nil
table.insert(removed, mapping)
end
end
return removed
end
---A newer API for adding items to the root.
---Only adds the item if the same item does not already exist in the root.
---@param item Item|string
---@param priority? number Specifies the insertion location, a lower priority
--- is placed higher in the list and the default is 100.
---@return boolean
function fb.register_root_item(item, priority)
msg.verbose('registering root item:', utils.to_string(item))
if type(item) == 'string' then
item = {name = item, type = 'dir'}
end
-- if the item is already in the list then do nothing
if fb.list.some(g.root, function(r)
return fb.get_full_path(r, '') == fb.get_full_path(item, '')
end) then return false end
---@type table<Item,number>
local priorities = {}
priorities[item] = priority
for i, v in ipairs(g.root) do
if (priorities[v] or 100) > (priority or 100) then
fb.insert_root_item(item, i)
return true
end
end
fb.insert_root_item(item)
return true
end
--providing getter and setter functions so that addons can't modify things directly
---@param key string
---@return boolean|string|number
function fb.get_opt(key) return o[key] end
function fb.get_script_opts() return fb.copy_table(o) end
function fb.get_platform() return g.PLATFORM end
function fb.get_extensions() return fb.copy_table(g.extensions) end
function fb.get_sub_extensions() return fb.copy_table(g.sub_extensions) end
function fb.get_audio_extensions() return fb.copy_table(g.audio_extensions) end
function fb.get_parseable_extensions() return fb.copy_table(g.parseable_extensions) end
function fb.get_state() return fb.copy_table(g.state) end
function fb.get_parsers() return fb.copy_table(g.parsers) end
function fb.get_root() return fb.copy_table(g.root) end
function fb.get_directory() return g.state.directory end
function fb.get_list() return fb.copy_table(g.state.list) end
function fb.get_current_file() return fb.copy_table(g.current_file) end
function fb.get_current_parser() return g.state.parser:get_id() end
function fb.get_current_parser_keyname() return g.state.parser.keybind_name or g.state.parser.name end
function fb.get_selected_index() return g.state.selected end
function fb.get_selected_item() return fb.copy_table(g.state.list[g.state.selected]) end
function fb.get_open_status() return not g.state.hidden end
function fb.get_parse_state(co) return g.parse_states[co or coroutine.running() or ""] end
function fb.get_history() return fb.copy_table(g.history.list) end
function fb.get_history_index() return g.history.position end
---@deprecated
---@return string|nil
function fb.get_dvd_device()
local dvd_device = mp.get_property('dvd-device')
if not dvd_device or dvd_device == '' then return nil end
return fb_utils.fix_path(dvd_device, true)
end
---@param str string
function fb.set_empty_text(str)
g.state.empty_text = str
fb.redraw()
end
---@param index number
---@return number|false
function fb.set_selected_index(index)
if type(index) ~= "number" then return false end
if index < 1 then index = 1 end
if index > #g.state.list then index = #g.state.list end
g.state.selected = index
fb.redraw()
return index
end
fb.set_history_index = directory_movement.goto_history
return fb
@@ -0,0 +1,34 @@
local msg = require 'mp.msg'
local g = require 'modules.globals'
---@class ParseStateAPI
local parse_state_API = {}
---A wrapper around coroutine.yield that aborts the coroutine if
--the parse request was cancelled by the user.
--the coroutine is
---@async
---@param self ParseState
---@param ... any
---@return unknown ...
function parse_state_API:yield(...)
local co = coroutine.running()
local is_browser = co == g.state.co
local result = table.pack(coroutine.yield(...))
if is_browser and co ~= g.state.co then
msg.verbose("browser no longer waiting for list - aborting parse for", self.directory)
error(g.ABORT_ERROR)
end
return table.unpack(result, 1, result.n)
end
---Checks if the current coroutine is the one handling the browser's request.
---@return boolean
function parse_state_API:is_coroutine_current()
return coroutine.running() == g.state.co
end
return parse_state_API
@@ -0,0 +1,40 @@
local msg = require 'mp.msg'
local g = require 'modules.globals'
local scanning = require 'modules.navigation.scanning'
local fb = require 'modules.apis.fb'
---@class ParserAPI: FbAPI
local parser_api = setmetatable({}, { __index = fb })
---Returns the index of the parser.
---@return number
function parser_api:get_index() return g.parsers[self].index end
---Returns the ID of the parser
---@return string
function parser_api:get_id() return g.parsers[self].id end
---A newer API for adding items to the root.
---Only adds the item if the same item does not already exist in the root.
---Wrapper around `fb.register_root_item`.
---@param item Item|string
---@param priority? number The priority for the added item. Uses the parsers priority by default.
---@return boolean
function parser_api:register_root_item(item, priority)
return fb.register_root_item(item, priority or g.parsers[self:get_id()].priority)
end
---Runs choose_and_parse starting from the next parser.
---@async
---@param directory string
---@return Item[]?
---@return Opts?
function parser_api:defer(directory)
msg.trace("deferring to other parsers...")
local list, opts = scanning.choose_and_parse(directory, self:get_index() + 1)
fb.get_parse_state().already_deferred = true
return list, opts
end
return parser_api
+238
View File
@@ -0,0 +1,238 @@
--------------------------------------------------------------------------------------------------------
-----------------------------------------List Formatting------------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
local g = require 'modules.globals'
local o = require 'modules.options'
local fb_utils = require 'modules.utils'
local state = g.state
local style = g.style
local ass = g.ass
--- https://www.unicode.org/reports/tr9/#Explicit_Directional_Isolates
local ISOLATE_DIRECTION_START = '\226\129\168' -- U+2068 FIRST STRONG ISOLATE
local ISOLATE_DIRECTION_END = '\226\129\169' -- U+2069 POP DIRECTIONAL ISOLATE
local function draw()
ass:update()
end
local function remove()
ass:remove()
end
---@type string[]
local string_buffer = {}
---appends the entered text to the overlay
---@param ... string
local function append(...)
for i = 1, select("#", ...) do
table.insert(string_buffer, select(i, ...) or '' )
end
end
--appends a newline character to the osd
local function newline()
table.insert(string_buffer, '\\N')
end
local function flush_buffer()
ass.data = table.concat(string_buffer, '')
string_buffer = {}
end
---detects whether or not to highlight the given entry as being played
---@param v Item
---@return boolean
local function highlight_entry(v)
if g.current_file.path == nil then return false end
local full_path = fb_utils.get_full_path(v)
local alt_path = v.name and g.state.directory..v.name or nil
if fb_utils.parseable_item(v) then
return (
string.find(g.current_file.directory, full_path, 1, true)
or (alt_path and string.find(g.current_file.directory, alt_path, 1, true))
) ~= nil
else
return g.current_file.path == full_path
or (alt_path and g.current_file.path == alt_path)
end
end
---Escapes unwanted unicode control characters that may affect the rest of the display.
---Currently this only isolates unicode directional overrides.
---Based on: https://github.com/mpv-player/mpv/pull/17606
---@param str string
---@return string
local function unicode_escape(str)
return ISOLATE_DIRECTION_START..str..ISOLATE_DIRECTION_END
end
---escape ass values and replace newlines
---@param str string
---@param style_reset string?
---@return string
local function ass_escape(str, style_reset)
return fb_utils.ass_escape(str, style_reset and style.warning..''..style_reset or true)
end
local header_overrides = {['^'] = style.header}
---@return number start
---@return number finish
---@return boolean is_overflowing
local function calculate_view_window()
---@type number
local start = 1
---@type number
local finish = start+o.num_entries-1
--handling cursor positioning
local mid = math.ceil(o.num_entries/2)+1
if state.selected+mid > finish then
---@type number
local offset = state.selected - finish + mid
--if we've overshot the end of the list then undo some of the offset
if finish + offset > #state.list then
offset = offset - ((finish+offset) - #state.list)
end
start = start + offset
finish = finish + offset
end
--making sure that we don't overstep the boundaries
if start < 1 then start = 1 end
local overflow = finish < #state.list
--this is necessary when the number of items in the dir is less than the max
if not overflow then finish = #state.list end
return start, finish, overflow
end
---@param i number index
---@return string
local function calculate_item_style(i)
local is_playing_file = highlight_entry(state.list[i])
--sets the selection colour scheme
local multiselected = state.selection[i]
--sets the colour for the item
local item_style = style.body
if multiselected then item_style = item_style..style.multiselect
elseif i == state.selected then item_style = item_style..style.selected end
if is_playing_file then item_style = item_style..(multiselected and style.playing_selected or style.playing) end
return item_style
end
local function draw_header()
append(style.header)
append(fb_utils.substitute_codes(o.format_string_header, header_overrides, nil, nil, function(str, code)
if code == '^' then return str end
return ass_escape(str, style.header)
end))
newline()
end
---@param wrapper_overrides ReplacerTable
local function draw_top_wrapper(wrapper_overrides)
--adding a header to show there are items above in the list
append(style.footer_header)
append(fb_utils.substitute_codes(o.format_string_topwrapper, wrapper_overrides, nil, nil, function(str)
return ass_escape(str)
end))
newline()
end
---@param wrapper_overrides ReplacerTable
local function draw_bottom_wrapper(wrapper_overrides)
append(style.footer_header)
append(fb_utils.substitute_codes(o.format_string_bottomwrapper, wrapper_overrides, nil, nil, function(str)
return ass_escape(str)
end))
end
---@param i number index
---@param cursor string
local function draw_cursor(i, cursor)
--handles custom styles for different entries
if i == state.selected or i == state.multiselect_start then
if not (i == state.selected) then append(style.selection_marker) end
if not state.multiselect_start then append(style.cursor)
else
if state.selection[state.multiselect_start] then append(style.cursor_select)
else append(style.cursor_deselect) end
end
else
append(g.style.indent)
end
append(cursor, '\\h', style.body)
end
--refreshes the ass text using the contents of the list
local function update_ass()
if state.hidden then state.flag_update = true ; return end
append(style.global)
draw_header()
if #state.list < 1 then
append(state.empty_text)
flush_buffer()
draw()
return
end
local start, finish, overflow = calculate_view_window()
-- these are the number values to place into the wrappers
local wrapper_overrides = {['<'] = tostring(start-1), ['>'] = tostring(#state.list-finish)}
if o.format_string_topwrapper ~= '' and start > 1 then
draw_top_wrapper(wrapper_overrides)
end
for i=start, finish do
local v = state.list[i]
append(style.body)
if g.ALIGN_X ~= 'right' then draw_cursor(i, o.cursor_icon) end
local item_style = calculate_item_style(i)
append(item_style)
--sets the folder icon
if v.type == 'dir' then
append(style.folder, o.folder_icon, "\\h", style.body)
append(item_style)
end
--adds the actual name of the item
append(v.ass or ass_escape( unicode_escape(v.label or v.name) , item_style), '\\h')
if g.ALIGN_X == 'right' then draw_cursor(i, o.cursor_icon_flipped) end
newline()
end
if o.format_string_bottomwrapper ~= '' and overflow then
draw_bottom_wrapper(wrapper_overrides)
end
flush_buffer()
draw()
end
---@class ass
return {
update_ass = update_ass,
highlight_entry = highlight_entry,
draw = draw,
remove = remove,
}
+94
View File
@@ -0,0 +1,94 @@
local mp = require 'mp'
local msg = require 'mp.msg'
local utils = require 'mp.utils'
local o = require 'modules.options'
local g = require 'modules.globals'
local fb_utils = require 'modules.utils'
local movement = require 'modules.navigation.directory-movement'
local ass = require 'modules.ass'
local cursor = require 'modules.navigation.cursor'
---@class controls
local controls = {}
--opens the browser
function controls.open()
if not g.state.hidden then return end
for _,v in ipairs(g.state.keybinds) do
mp.add_forced_key_binding(v[1], 'dynamic/'..v[2], v[3], v[4])
end
if o.set_shared_script_properties then utils.shared_script_property_set('file_browser-open', 'yes') end ---@diagnostic disable-line deprecated
if o.set_user_data then mp.set_property_bool('user-data/file_browser/open', true) end
if o.toggle_idlescreen then mp.commandv('script-message', 'osc-idlescreen', 'no', 'no_osd') end
g.state.hidden = false
if g.state.directory == nil then
local path = mp.get_property('path')
if path or o.default_to_working_directory then movement.goto_current_dir() else movement.goto_root() end
return
end
if not g.state.flag_update then ass.draw()
else g.state.flag_update = false ; ass.update_ass() end
end
--closes the list and sets the hidden flag
function controls.close()
if g.state.hidden then return end
for _,v in ipairs(g.state.keybinds) do
mp.remove_key_binding('dynamic/'..v[2])
end
if o.set_shared_script_properties then utils.shared_script_property_set("file_browser-open", "no") end ---@diagnostic disable-line deprecated
if o.set_user_data then mp.set_property_bool('user-data/file_browser/open', false) end
if o.toggle_idlescreen then mp.commandv('script-message', 'osc-idlescreen', 'yes', 'no_osd') end
g.state.hidden = true
ass.remove()
end
--toggles the list
function controls.toggle()
if g.state.hidden then controls.open()
else controls.close() end
end
--run when the escape key is used
function controls.escape()
--if multiple items are selection cancel the
--selection instead of closing the browser
if next(g.state.selection) or g.state.multiselect_start then
g.state.selection = {}
cursor.disable_select_mode()
ass.update_ass()
return
end
controls.close()
end
---opens a specific directory
---@param directory string
---@param open_browser? boolean
---@return thread|nil
function controls.browse_directory(directory, open_browser)
if not directory then return end
if open_browser == nil then open_browser = true end
directory = mp.command_native({"expand-path", directory}, '') --[[@as string]]
-- directory = join_path( mp.get_property("working-directory", ""), directory )
if directory ~= "" then directory = fb_utils.fix_path(directory, true) end
msg.verbose('recieved directory from script message: '..directory)
directory = fb_utils.resolve_directory_mapping(directory)
local co = movement.goto_directory(directory, nil, nil, {cache={use=false}})
if open_browser then controls.open() end
return co
end
return controls
@@ -0,0 +1,3 @@
---@meta file-browser
return require 'modules.apis.fb'
@@ -0,0 +1,39 @@
---@meta _
---@class KeybindFlags
---@field repeatable boolean?
---@field scalable boolean?
---@field complex boolean?
---@class KeybindCommandTable
---@class Keybind
---@field key string
---@field command KeybindCommand
---@field api_version string?
---
---@field name string?
---@field condition string?
---@field flags KeybindFlags?
---@field filter ('file'|'dir')?
---@field parser string?
---@field multiselect boolean?
---@field multi-type ('repeat'|'concat')?
---@field delay number?
---@field concat-string string?
---@field passthrough boolean?
---
---@field prev_key Keybind? The keybind that was previously set to the same key.
---@field codes Set<string>? Any substituation codes used by the command table.
---@field condition_codes Set<string>? Any substitution codes used by the condition string.
---@field addon boolean? Whether the keybind was created by an addon.
---@alias KeybindFunctionCallback async fun(keybind: Keybind, state: State, co: thread)
---@alias KeybindCommand KeybindFunctionCallback|KeybindCommandTable[]
---@alias KeybindTuple [string,string,KeybindCommand,KeybindFlags?]
---@alias KeybindTupleStrict [string,string,KeybindFunctionCallback,KeybindFlags?]
---@alias KeybindList (Keybind|KeybindTuple)[]
@@ -0,0 +1,25 @@
---@meta _
---@alias List Item[]
---Represents an item returned by the parsers.
---@class Item
---@field type 'file'|'dir'
---@field name string
---@field label string?
---@field path string?
---@field ass string?
---@field redirect boolean?
---@field mpv_options string|{[string]: unknown}?
---The Opts table returned by the parsers.
---@class Opts
---@field filtered boolean?
---@field sorted boolean?
---@field directory string?
---@field directory_label string?
---@field empty_text string?
---@field selected_index number?
---@field id string?
---@field parser Parser?
@@ -0,0 +1,148 @@
---@meta mp
---@class mp
local mp = {}
---@class AsyncReturn
---@class MPTimer
---@field stop fun(self: MPTimer)
---@field kill fun(self: MPTimer)
---@field resume fun(self: MPTimer)
---@field is_enabled fun(self: MPTimer): boolean
---@field timeout number
---@field oneshot boolean
---@class OSDOverlay
---@field data string
---@field res_x number
---@field res_y number
---@field z number
---@field update fun(self:OSDOverlay)
---@field remove fun(self: OSDOverlay)
---@class MPVSubprocessResult
---@field status number
---@field stdout string
---@field stderr string
---@field error_string ''|'killed'|'init'
---@field killed_by_us boolean
---@param key string
---@param name_or_fn string|function
---@param fn? async fun()
---@param flags? KeybindFlags
function mp.add_key_binding(key, name_or_fn, fn, flags) end
---@param key string
---@param name_or_fn string|function
---@param fn? async fun()
---@param flags? KeybindFlags
function mp.add_forced_key_binding(key, name_or_fn, fn, flags) end
---@param seconds number
---@param fn function
---@param disabled? boolean
---@return MPTimer
function mp.add_timeout(seconds, fn, disabled) end
---@param format 'ass-events'
---@return OSDOverlay
function mp.create_osd_overlay(format) end
---@param ... string
function mp.commandv(...) end
---@generic T
---@param t table
---@param def? T
---@return unknown|T result
---@return string? error
---@overload fun(t: table): (unknown|nil, string?)
function mp.command_native(t, def) end
---@nodiscard
---@param t table
---@param cb fun(success: boolean, result: unknown, error: string?)
---@return AsyncReturn
function mp.command_native_async(t, cb) end
---@param t AsyncReturn
function mp.abort_async_command(t) end
---@generic T
---@param name string
---@param def? T
---@return string|T
---@overload fun(name: string): string|nil
function mp.get_property(name, def) end
---@generic T
---@param name string
---@param def? T
---@return boolean|T
---@overload fun(name: string): boolean|nil
function mp.get_property_bool(name, def) end
---@generic T
---@param name string
---@param def? T
---@return number|T
---@overload fun(name: string): number|nil
function mp.get_property_number(name, def) end
---@generic T
---@param name string
---@param def? T
---@return unknown|T
---@overload fun(name: string): unknown|nil
function mp.get_property_native(name, def) end
---@return string|nil
function mp.get_script_directory() end
---@return string
function mp.get_script_name() end
---@param name string
---@param type 'native'|'bool'|'string'|'number'
---@param fn fun(name: string, v: unknown)
function mp.observe_property(name, type, fn) end
---@param name string
---@param fn function
---@return boolean
function mp.register_event(name, fn) end
---@param name string
---@param fn fun(...: string)
function mp.register_script_message(name, fn) end
---@param name string
function mp.remove_key_binding(name) end
---@param name string
---@param value string
---@return true? success # nil if error
---@return string? err
function mp.set_property(name, value) end
---@param name string
---@param value boolean
---@return true? success # nil if error
---@return string? err
function mp.set_property_bool(name, value) end
---@param name string
---@param value number
---@return true? success # nil if error
---@return string? err
function mp.set_property_number(name, value) end
---@param name string
---@param value any
---@return true? success # nil if error
---@return string? err
function mp.set_property_native(name, value) end
return mp
@@ -0,0 +1,21 @@
---@meta mp.input
---@class mp.input
local input = {}
---@class InputGetOpts
---@field prompt string?
---@field default_text string?
---@field id string?
---@field submit (fun(text: string))?
---@field opened (fun())?
---@field edited (fun(text: string))?
---@field complete (fun(text_before_cursor: string): string[], number)?
---@field closed (fun(text: string))?
---@param options InputGetOpts
function input.get(options) end
function input.terminate() end
return input
@@ -0,0 +1,32 @@
---@meta mp.msg
---@class mp.msg
local msg = {}
---@param level 'fatal'|'error'|'warn'|'info'|'v'|'debug'|'trace'
---@param ... any
function msg.log(level, ...) end
---@param ... any
function msg.fatal(...) end
---@param ... any
function msg.error(...) end
---@param ... any
function msg.warn(...) end
---@param ... any
function msg.info(...) end
---@param ... any
function msg.verbose(...) end
---@param ... any
function msg.debug(...) end
---@param ... any
function msg.trace(...) end
return msg
@@ -0,0 +1,11 @@
---@meta mp.options
---@class mp.options
local options = {}
---@param t table<string,string|number|boolean>
---@param identifier? string
---@param on_update? fun(list: table<string,true|nil>)
function options.read_options(t, identifier, on_update) end
return options
@@ -0,0 +1,43 @@
---@meta mp.utils
---@class mp.utils
local utils = {}
---@param v string|boolean|number|table|nil
---@return string? json # nil on error
---@return string? err # error
function utils.format_json(v) end
---@param p1 string
---@param p2 string
---@return string
function utils.join_path(p1, p2) end
---@param str string
---@param trail? boolean
---@return (table|unknown[])? t
---@return string? err # error
---@return string trail # trailing characters
function utils.parse_json(str, trail) end
---@param path string
---@param filter ('files'|'dirs'|'normal'|'all')?
---@return string[]? # nil on error
---@return string? err # error
function utils.readdir(path, filter) end
---@deprecated
---@param name string
---@param value string
function utils.shared_script_property_set(name, value) end
---@param path string
---@return string directory
---@return string filename
function utils.split_path(path) end
---@param v any
---@return string
function utils.to_string(v) end
return utils
@@ -0,0 +1,41 @@
---@meta _
---A ParserConfig object returned by addons
---@class (partial) ParserConfig: ParserAPI
---@field priority number?
---@field api_version string The minimum API version the string requires.
---@field version string? The minimum API version the string requires. @deprecated.
---
---@field can_parse (async fun(self: Parser, directory: string, parse_state: ParseState): boolean)?
---@field parse (async fun(self: Parser, directory: string, parse_state: ParseState): List?, Opts?)?
---@field setup fun(self: Parser)?
---
---@field name string?
---@field keybind_name string?
---@field keybinds KeybindList?
---The parser object used by file-browser once the parsers have been loaded and initialised.
---@class Parser: ParserAPI, ParserConfig
---@field name string
---@field priority number
---@field api_version string
---@field can_parse async fun(self: Parser, directory: string, parse_state: ParseState): boolean
---@field parse async fun(self: Parser, directory: string, parse_state: ParseState): List?, Opts?
---@alias ParseStateSource 'browser'|'loadlist'|'script-message'|'addon'|string
---@alias ParseProperties table<string,any>
---The Parse State object passed to the can_parse and parse methods
---@class ParseStateFields
---@field source ParseStateSource
---@field directory string
---@field already_deferred boolean?
---@field properties ParseProperties
---@class ParseState: ParseStateFields, ParseStateAPI
---@class ParseStateTemplate
---@field source ParseStateSource?
---@field properties ParseProperties?
@@ -0,0 +1,21 @@
---@meta _
---@class Set<T>: {[T]: boolean}
---@class (exact) State
---@field list List
---@field selected number
---@field hidden boolean
---@field flag_update boolean
---@field keybinds KeybindTupleStrict[]?
---
---@field parser Parser?
---@field directory string?
---@field directory_label string?
---@field prev_directory string
---@field empty_text string
---@field co thread?
---
---@field multiselect_start number?
---@field initial_selection Set<number>?
---@field selection Set<number>?
@@ -0,0 +1,28 @@
---@meta user-input-module
---@class user_input_module
local user_input_module = {}
---@class UserInputOpts
---@field id string?
---@field source string?
---@field request_text string?
---@field default_input string?
---@field cursor_pos number?
---@field queueable boolean?
---@field replace boolean?
---@class UserInputRequest
---@field callback function?
---@field passthrough_args any[]?
---@field pending boolean
---@field cancel fun(self: UserInputRequest)
---@field update fun(self: UserInputRequest, opts: UserInputOpts)
---@param fn function
---@param opts UserInputOpts
---@param ... any passthrough arguments
---@return UserInputRequest
function user_input_module.get_user_input(fn, opts, ...) end
return user_input_module
+181
View File
@@ -0,0 +1,181 @@
--------------------------------------------------------------------------------------------------------
------------------------------------------Variable Setup------------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
local mp = require 'mp'
local o = require 'modules.options'
---@class globals
local globals = {}
--sets the version for the file-browser API
globals.API_VERSION = "1.9.0"
---gets the current platform (in mpv v0.36+)
---in earlier versions it is set to `windows`, `darwin` or `other`
---@type 'windows'|'darwin'|'linux'|'android'|'freebsd'|'other'|string|nil
globals.PLATFORM = mp.get_property_native('platform')
if not globals.PLATFORM then
local _ = {}
if mp.get_property_native('options/vo-mmcss-profile', _) ~= _ then
globals.PLATFORM = 'windows'
elseif mp.get_property_native('options/macos-force-dedicated-gpu', _) ~= _ then
globals.PLATFORM = 'darwin'
end
return 'other'
end
--the osd_overlay API was not added until v0.31. The expand-path command was not added until 0.30
assert(mp.create_osd_overlay, "Script requires minimum mpv version 0.33")
globals.ass = mp.create_osd_overlay("ass-events")
globals.ass.res_y = 720 / o.scaling_factor_base
local BASE_FONT_SIZE = 25
--force file-browser to use a specific text alignment (default: top-left)
--uses ass tag alignment numbers: https://aegi.vmoe.info/docs/3.0/ASS_Tags/#index23h3
globals.ASS_ALIGNMENT_MATRIX = {
top = {left = 7, center = 8, right = 9},
center = {left = 4, center = 5, right = 6},
bottom = {left = 1, center = 2, right = 3},
}
globals.ALIGN_X = o.align_x == 'auto' and mp.get_property('osd-align-x', 'left') or o.align_x
globals.ALIGN_Y = o.align_y == 'auto' and mp.get_property('osd-align-y', 'top') or o.align_y
globals.style = {
global = ([[{\an%d}]]):format(globals.ASS_ALIGNMENT_MATRIX[globals.ALIGN_Y][globals.ALIGN_X]),
-- full line styles
header = ([[{\r\q2\b%s\fs%d\fn%s\c&H%s&}]]):format((o.font_bold_header and "1" or "0"), o.scaling_factor_header*BASE_FONT_SIZE, o.font_name_header, o.font_colour_header),
body = ([[{\r\q2\fs%d\fn%s\c&H%s&}]]):format(BASE_FONT_SIZE, o.font_name_body, o.font_colour_body),
footer_header = ([[{\r\q2\fs%d\fn%s\c&H%s&}]]):format(o.scaling_factor_wrappers*BASE_FONT_SIZE, o.font_name_wrappers, o.font_colour_wrappers),
--small section styles (for colours)
multiselect = ([[{\c&H%s&}]]):format(o.font_colour_multiselect),
selected = ([[{\c&H%s&}]]):format(o.font_colour_selected),
playing = ([[{\c&H%s&}]]):format(o.font_colour_playing),
playing_selected = ([[{\c&H%s&}]]):format(o.font_colour_playing_multiselected),
warning = ([[{\c&H%s&}]]):format(o.font_colour_escape_chars),
--icon styles
indent = ([[{\alpha&H%s}]]):format('ff'),
cursor = ([[{\fn%s\c&H%s&}]]):format(o.font_name_cursor, o.font_colour_cursor),
cursor_select = ([[{\fn%s\c&H%s&}]]):format(o.font_name_cursor, o.font_colour_multiselect),
cursor_deselect = ([[{\fn%s\c&H%s&}]]):format(o.font_name_cursor, o.font_colour_selected),
folder = ([[{\fn%s}]]):format(o.font_name_folder),
selection_marker = ([[{\alpha&H%s}]]):format(o.font_opacity_selection_marker),
}
---@type State
globals.state = {
list = {},
selected = 1,
hidden = true,
flag_update = false,
keybinds = nil,
parser = nil,
directory = nil,
directory_label = nil,
prev_directory = '',
empty_text = 'Empty Directory',
co = nil,
multiselect_start = nil,
initial_selection = nil,
selection = {}
}
---@class ParserRef
---@field id string
---@field index number?
---@type table<number,Parser>|table<string,Parser>|table<Parser,ParserRef>>
--the parser table actually contains 3 entries for each parser
--a numeric entry which represents the priority of the parsers and has the parser object as the value
--a string entry representing the id of each parser and with the parser object as the value
--and a table entry with the parser itself as the key and a table value in the form { id = %s, index = %d }
globals.parsers = {}
--this table contains the parse_state tables for every parse operation indexed with the coroutine used for the parse
--this table has weakly referenced keys, meaning that once the coroutine for a parse is no-longer used by anything that
--field in the table will be removed by the garbage collector
---@type table<thread,ParseState>
globals.parse_states = setmetatable({}, { __mode = "k"})
---@type Set<string>
globals.extensions = {}
---@type Set<string>
globals.sub_extensions = {}
---@type Set<string>
globals.audio_extensions = {}
---@type Set<string>
globals.parseable_extensions = {}
---This table contains mappings to convert external directories to cannonical
--locations within the file-browser file tree. The keys of the table are Lua
--patterns used to evaluate external directory paths. The value is the path
--that should replace the part of the path than matched the pattern.
--These mappings should only applied at the edges where external paths are
--ingested by file-browser.
---@type table<string,string>
globals.directory_mappings = {}
---@class CurrentFile
---@field directory string?
---@field name string?
---@field path string?
---@field original_path string?
globals.current_file = {
directory = nil,
name = nil,
path = nil,
original_path = nil,
}
---@type List
globals.root = {}
---@class (strict) History
---@field list string[]
---@field size number
---@field position number
globals.history = {
list = {},
size = 0,
position = 0,
}
---@class (strict) DirectoryStack
---@field stack string[]
---@field position number
globals.directory_stack = {
stack = {},
position = 0,
}
--default list of compatible file extensions
--adding an item to this list is a valid request on github
globals.compatible_file_extensions = {
"264","265","3g2","3ga","3ga2","3gp","3gp2","3gpp","3iv","a52","aac","adt","adts","ahn","aif","aifc","aiff","amr","ape","asf","au","avc","avi","awb","ay",
"bmp","cue","divx","dts","dtshd","dts-hd","dv","dvr","dvr-ms","eac3","evo","evob","f4a","flac","flc","fli","flic","flv","gbs","gif","gxf","gym",
"h264","h265","hdmov","hdv","hes","hevc","jpeg","jpg","kss","lpcm","m1a","m1v","m2a","m2t","m2ts","m2v","m3u","m3u8","m4a","m4v","mk3d","mka","mkv",
"mlp","mod","mov","mp1","mp2","mp2v","mp3","mp4","mp4v","mp4v","mpa","mpe","mpeg","mpeg2","mpeg4","mpg","mpg4","mpv","mpv2","mts","mtv","mxf","nsf",
"nsfe","nsv","nut","oga","ogg","ogm","ogv","ogx","opus","pcm","pls","png","qt","ra","ram","rm","rmvb","sap","snd","spc","spx","svg","thd","thd+ac3",
"tif","tiff","tod","trp","truehd","true-hd","ts","tsa","tsv","tta","tts","vfw","vgm","vgz","vob","vro","wav","weba","webm","webp","wm","wma","wmv","wtv",
"wv","x264","x265","xvid","y4m","yuv"
}
---@class BrowserAbortError
globals.ABORT_ERROR = {
msg = "browser is no longer waiting for list - aborting parse"
}
return globals
+354
View File
@@ -0,0 +1,354 @@
------------------------------------------------------------------------------------------
----------------------------------Keybind Implementation----------------------------------
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
local mp = require 'mp'
local msg = require 'mp.msg'
local utils = require 'mp.utils'
local o = require 'modules.options'
local g = require 'modules.globals'
local fb_utils = require 'modules.utils'
local addons = require 'modules.addons'
local playlist = require 'modules.playlist'
local controls = require 'modules.controls'
local movement = require 'modules.navigation.directory-movement'
local scanning = require 'modules.navigation.scanning'
local cursor = require 'modules.navigation.cursor'
g.state.keybinds = {
{'ENTER', 'play', function() playlist.add_files('replace', false) end},
{'Shift+ENTER', 'play_append', function() playlist.add_files('append-play', false) end},
{'Alt+ENTER', 'play_autoload', function() playlist.add_files('replace', true) end},
{'ESC', 'close', controls.escape},
{'RIGHT', 'down_dir', movement.down_dir},
{'LEFT', 'up_dir', movement.up_dir},
{'Alt+RIGHT', 'history_forward', movement.forwards_history},
{'Alt+LEFT', 'history_back', movement.back_history},
{'DOWN', 'scroll_down', function() cursor.scroll(1, o.wrap) end, {repeatable = true}},
{'UP', 'scroll_up', function() cursor.scroll(-1, o.wrap) end, {repeatable = true}},
{'PGDWN', 'page_down', function() cursor.scroll(o.num_entries) end, {repeatable = true}},
{'PGUP', 'page_up', function() cursor.scroll(-o.num_entries) end, {repeatable = true}},
{'Shift+PGDWN', 'list_bottom', function() cursor.scroll(math.huge) end},
{'Shift+PGUP', 'list_top', function() cursor.scroll(-math.huge) end},
{'HOME', 'goto_current', movement.goto_current_dir},
{'Shift+HOME', 'goto_root', movement.goto_root},
{'Ctrl+r', 'reload', function() scanning.rescan() end},
{'s', 'select_mode', cursor.toggle_select_mode},
{'S', 'select_item', cursor.toggle_selection},
{'Ctrl+a', 'select_all', cursor.select_all}
}
---a map of key-keybinds - only saves the latest keybind if multiple have the same key code
---@type KeybindList
local top_level_keys = {}
---Format the item string for either single or multiple items.
---@param base_code_fn Replacer
---@param items Item[]
---@param state State
---@param cmd Keybind
---@param quoted? boolean
---@return string|nil
local function create_item_string(base_code_fn, items, state, cmd, quoted)
if not items[1] then return end
local func = quoted and function(...) return ("%q"):format(base_code_fn(...)) end or base_code_fn
local out = {}
for _, item in ipairs(items) do
table.insert(out, func(item, state))
end
return table.concat(out, cmd['concat-string'] or ' ')
end
local KEYBIND_CODE_PATTERN = fb_utils.get_code_pattern(fb_utils.code_fns)
local item_specific_codes = 'fnij'
---Replaces codes in the given string using the replacers.
---@param str string
---@param cmd Keybind
---@param items Item[]
---@param state State
---@return string
local function substitute_codes(str, cmd, items, state)
---@type ReplacerTable
local overrides = {}
for code in item_specific_codes:gmatch('.') do
overrides[code] = function(_,s) return create_item_string(fb_utils.code_fns[code], items, s, cmd) end
overrides[code:upper()] = function(_,s) return create_item_string(fb_utils.code_fns[code], items, s, cmd, true) end
end
return fb_utils.substitute_codes(str, overrides, items[1], state)
end
---Iterates through the command table and substitutes special
---character codes for the correct strings used for custom functions.
---@param cmd Keybind
---@param items Item[]
---@param state State
---@return KeybindCommand
local function format_command_table(cmd, items, state)
local command = cmd.command
if type(command) == 'function' then return command end
---@type string[][]
local copy = {}
for i = 1, #command do
---@type string[]
copy[i] = {}
for j = 1, #command[i] do
copy[i][j] = substitute_codes(cmd.command[i][j], cmd, items, state)
end
end
return copy
end
---Runs all of the commands in the command table.
---@param cmd Keybind key.command must be an array of command tables compatible with mp.command_native
---@param items Item[] must be an array of multiple items (when multi-type ~= concat the array will be 1 long).
---@param state State
local function run_custom_command(cmd, items, state)
local custom_cmds = cmd.codes and format_command_table(cmd, items, state) or cmd.command
if type(custom_cmds) == 'function' then
error(('attempting to run a function keybind as a command table keybind\n%s'):format(utils.to_string(cmd)))
end
for _, custom_cmd in ipairs(custom_cmds) do
msg.debug("running command:", utils.to_string(custom_cmd))
mp.command_native(custom_cmd)
end
end
---returns true if the given code set has item specific codes (%f, %i, etc)
---@param codes Set<string>
---@return boolean
local function has_item_codes(codes)
for code in pairs(codes) do
if item_specific_codes:find(code:lower(), 1, true) then return true end
end
return false
end
---Runs one of the custom commands.
---@async
---@param cmd Keybind
---@param state State
---@param co thread
---@return boolean|nil
local function run_custom_keybind(cmd, state, co)
--evaluates a condition and passes through the correct values
local function evaluate_condition(condition, items)
local cond = substitute_codes(condition, cmd, items, state)
return fb_utils.evaluate_string('return '..cond) == true
end
-- evaluates the string condition to decide if the keybind should be run
---@type boolean
local do_item_condition
if cmd.condition then
if has_item_codes(cmd.condition_codes) then
do_item_condition = true
elseif not evaluate_condition(cmd.condition, {}) then
return false
end
end
if cmd.parser then
local parser_str = ' '..cmd.parser..' '
if not parser_str:find( '%W'..(state.parser.keybind_name or state.parser.name)..'%W' ) then return false end
end
--these are for the default keybinds, or from addons which use direct functions
if type(cmd.command) == 'function' then return cmd.command(cmd, cmd.addon and fb_utils.copy_table(state) or state, co) end
--the function terminates here if we are running the command on a single item
if not (cmd.multiselect and next(state.selection)) then
if cmd.filter then
if not state.list[state.selected] then return false end
if state.list[state.selected].type ~= cmd.filter then return false end
end
if cmd.codes then
--if the directory is empty, and this command needs to work on an item, then abort and fallback to the next command
if not state.list[state.selected] and has_item_codes(cmd.codes) then return false end
end
if do_item_condition and not evaluate_condition(cmd.condition, { state.list[state.selected] }) then
return false
end
run_custom_command(cmd, { state.list[state.selected] }, state)
return true
end
--runs the command on all multi-selected items
local selection = fb_utils.sort_keys(state.selection, function(item)
if do_item_condition and not evaluate_condition(cmd.condition, { item }) then return false end
return not cmd.filter or item.type == cmd.filter
end)
if not next(selection) then return false end
if cmd["multi-type"] == "concat" then
run_custom_command(cmd, selection, state)
elseif cmd["multi-type"] == "repeat" or cmd["multi-type"] == nil then
for i,_ in ipairs(selection) do
run_custom_command(cmd, {selection[i]}, state)
if cmd.delay then
mp.add_timeout(cmd.delay, function() fb_utils.coroutine.resume_err(co) end)
coroutine.yield()
end
end
end
--we passthrough by default if the command is not run on every selected item
if cmd.passthrough ~= nil then return end
local num_selection = 0
for _ in pairs(state.selection) do num_selection = num_selection+1 end
return #selection == num_selection
end
---Recursively runs the keybind functions, passing down through the chain
---of keybinds with the same key value.
---@async
---@param keybind Keybind
---@param state State
---@param co thread
local function run_keybind_recursive(keybind, state, co)
msg.trace("Attempting custom command:", utils.to_string(keybind))
if keybind.passthrough ~= nil then
run_custom_keybind(keybind, state, co)
if keybind.passthrough == true and keybind.prev_key then
run_keybind_recursive(keybind.prev_key, state, co)
end
else
if run_custom_keybind(keybind, state, co) == false and keybind.prev_key then
run_keybind_recursive(keybind.prev_key, state, co)
end
end
end
---A wrapper to run a custom keybind as a lua coroutine.
---@param key Keybind
local function run_keybind_coroutine(key)
msg.debug("Received custom keybind "..key.key)
local co = coroutine.create(run_keybind_recursive)
local state_copy = {
directory = g.state.directory,
directory_label = g.state.directory_label,
list = g.state.list, --the list should remain unchanged once it has been saved to the global state, new directories get new tables
selected = g.state.selected,
selection = fb_utils.copy_table(g.state.selection),
parser = g.state.parser,
}
local success, err = coroutine.resume(co, key, state_copy, co)
if not success then
msg.error("error running keybind:", utils.to_string(key))
fb_utils.traceback(err, co)
end
end
---Scans the given command table to identify if they contain any custom keybind codes.
---@param command_table KeybindCommand
---@param codes Set<string>
---@return Set<string>
local function scan_for_codes(command_table, codes)
if type(command_table) ~= "table" then return codes end
for _, value in pairs(command_table) do
local type = type(value)
if type == "table" then
scan_for_codes(value, codes)
elseif type == "string" then
for code in value:gmatch(KEYBIND_CODE_PATTERN) do
codes[code] = true
end
end
end
return codes
end
---Inserting the custom keybind into the keybind array for declaration when file-browser is opened.
---Custom keybinds with matching names will overwrite eachother.
---@param keybind Keybind
local function insert_custom_keybind(keybind)
-- api checking for the keybinds is optional, so set to a valid version if it does not exist
keybind.api_version = keybind.api_version or '1.0.0'
if not addons.check_api_version(keybind, 'keybind '..keybind.name) then return end
local command = keybind.command
--we'll always save the keybinds as either an array of command arrays or a function
if type(command) == "table" and type(command[1]) ~= "table" then
keybind.command = {command}
end
keybind.codes = scan_for_codes(keybind.command, {})
if not next(keybind.codes) then keybind.codes = nil end
keybind.prev_key = top_level_keys[keybind.key]
if keybind.condition then
keybind.condition_codes = {}
for code in string.gmatch(keybind.condition, KEYBIND_CODE_PATTERN) do keybind.condition_codes[code] = true end
end
table.insert(g.state.keybinds, {keybind.key, keybind.name, function() run_keybind_coroutine(keybind) end, keybind.flags or {}})
top_level_keys[keybind.key] = keybind
end
---Loading the custom keybinds.
---Can either load keybinds from the config file, from addons, or from both.
local function setup_keybinds()
--this is to make the default keybinds compatible with passthrough from custom keybinds
for _, keybind in ipairs(g.state.keybinds) do
top_level_keys[keybind[1]] = { key = keybind[1], name = keybind[2], command = keybind[3], flags = keybind[4] }
end
--this loads keybinds from addons
for i = #g.parsers, 1, -1 do
local parser = g.parsers[i]
if parser.keybinds then
for i, keybind in ipairs(parser.keybinds) do
--if addons use the native array command format, then we need to convert them over to the custom command format
if not keybind.key then keybind = { key = keybind[1], name = keybind[2], command = keybind[3], flags = keybind[4] }
else keybind = fb_utils.copy_table(keybind) end
keybind.name = g.parsers[parser].id.."/"..(keybind.name or tostring(i))
keybind.addon = true
insert_custom_keybind(keybind)
end
end
end
--loads custom keybinds from file-browser-keybinds.json
if o.custom_keybinds then
local path = mp.command_native({"expand-path", o.custom_keybinds_file}) --[[@as string]]
local custom_keybinds, err = io.open( path )
if not custom_keybinds then
msg.debug(err)
msg.verbose('could not read custom keybind file', path)
return
end
local json = custom_keybinds:read("*a")
custom_keybinds:close()
json = utils.parse_json(json)
if not json then return error("invalid json syntax for "..path) end
for i, keybind in ipairs(json --[[@as KeybindList]]) do
keybind.name = "custom/"..(keybind.name or tostring(i))
insert_custom_keybind(keybind)
end
end
end
---@class keybinds
return {
setup_keybinds = setup_keybinds,
}
@@ -0,0 +1,134 @@
--------------------------------------------------------------------------------------------------------
--------------------------------Scroll/Select Implementation--------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
local g = require 'modules.globals'
local fb_utils = require 'modules.utils'
local ass = require 'modules.ass'
---@class cursor
local cursor = {}
--disables multiselect
function cursor.disable_select_mode()
g.state.multiselect_start = nil
g.state.initial_selection = nil
end
--enables multiselect
function cursor.enable_select_mode()
g.state.multiselect_start = g.state.selected
g.state.initial_selection = fb_utils.copy_table(g.state.selection)
end
--calculates what drag behaviour is required for that specific movement
local function drag_select(original_pos, new_pos)
if original_pos == new_pos then return end
local setting = g.state.selection[g.state.multiselect_start or -1]
for i = original_pos, new_pos, (new_pos > original_pos and 1 or -1) do
--if we're moving the cursor away from the starting point then set the selection
--otherwise restore the original selection
if i > g.state.multiselect_start then
if new_pos > original_pos then
g.state.selection[i] = setting
elseif i ~= new_pos then
g.state.selection[i] = g.state.initial_selection[i]
end
elseif i < g.state.multiselect_start then
if new_pos < original_pos then
g.state.selection[i] = setting
elseif i ~= new_pos then
g.state.selection[i] = g.state.initial_selection[i]
end
end
end
end
--moves the selector up and down the list by the entered amount
function cursor.scroll(n, wrap)
local num_items = #g.state.list
if num_items == 0 then return end
local original_pos = g.state.selected
if original_pos + n > num_items then
g.state.selected = wrap and 1 or num_items
elseif original_pos + n < 1 then
g.state.selected = wrap and num_items or 1
else
g.state.selected = original_pos + n
end
if g.state.multiselect_start then drag_select(original_pos, g.state.selected) end
ass.update_ass()
end
--selects the first item in the list which is highlighted as playing
function cursor.select_playing_item()
for i,item in ipairs(g.state.list) do
if ass.highlight_entry(item) then
g.state.selected = i
return
end
end
end
--scans the list for which item to select by default
--chooses the folder that the script just moved out of
--or, otherwise, the item highlighted as currently playing
function cursor.select_prev_directory()
-- makes use of the directory stack to more exactly select the prev directory
local down_stack = g.directory_stack.stack[g.directory_stack.position + 1]
if down_stack then
for i, item in ipairs(g.state.list) do
if fb_utils.get_new_directory(item, g.state.directory) == down_stack then
g.state.selected = i
return
end
end
end
if g.state.prev_directory:find(g.state.directory, 1, true) == 1 then
for i, item in ipairs(g.state.list) do
if
g.state.prev_directory:find(fb_utils.get_full_path(item), 1, true) or
g.state.prev_directory:find(fb_utils.get_new_directory(item, g.state.directory), 1, true)
then
g.state.selected = i
return
end
end
end
cursor.select_playing_item()
end
--toggles the selection
function cursor.toggle_selection()
if not g.state.list[g.state.selected] then return end
g.state.selection[g.state.selected] = not g.state.selection[g.state.selected] or nil
ass.update_ass()
end
--select all items in the list
function cursor.select_all()
for i,_ in ipairs(g.state.list) do
g.state.selection[i] = true
end
ass.update_ass()
end
--toggles select mode
function cursor.toggle_select_mode()
if g.state.multiselect_start == nil then
cursor.enable_select_mode()
cursor.toggle_selection()
else
cursor.disable_select_mode()
ass.update_ass()
end
end
return cursor
@@ -0,0 +1,209 @@
local mp = require 'mp'
local msg = require 'mp.msg'
local utils = require 'mp.utils'
local o = require 'modules.options'
local g = require 'modules.globals'
local ass = require 'modules.ass'
local scanning = require 'modules.navigation.scanning'
local fb_utils = require 'modules.utils'
local cursor = require 'modules.navigation.cursor'
---@class directory_movement
local directory_movement = {}
local NavType = scanning.NavType
---Appends an item to the directory stack, wiping any
---directories further ahead than the current position.
---@param dir string
local function directory_stack_append(dir)
-- don't clear the stack if we're re-entering the same directory
if g.directory_stack.stack[g.directory_stack.position + 1] == dir then
g.directory_stack.position = g.directory_stack.position + 1
return
end
local j = #g.directory_stack.stack
while g.directory_stack.position < j do
g.directory_stack.stack[j] = nil
j = j - 1
end
table.insert(g.directory_stack.stack, dir)
g.directory_stack.position = g.directory_stack.position + 1
end
---@param dir string
local function directory_stack_prepend(dir)
table.insert(g.directory_stack.stack, 1, dir)
g.directory_stack.position = 1
end
---Clears directories from the history
---@param from? number All entries >= this index are cleared.
---@return string[]
function directory_movement.clear_history(from)
---@type string[]
local cleared = {}
from = from or 1
for i = g.history.size, from, -1 do
table.insert(cleared, g.history.list[i])
g.history.list[i] = nil
g.history.size = g.history.size - 1
if g.history.position >= i then
g.history.position = g.history.position - 1
end
end
return cleared
end
---Append a directory to the history
---If we have navigated backward in the history,
---then clear any history beyond the current point.
---@param directory string
function directory_movement.append_history(directory)
if g.history.list[g.history.position] == directory then
msg.debug('reloading same directory - history unchanged:', directory)
return
end
msg.debug('appending to history:', directory)
if g.history.position < g.history.size then
directory_movement.clear_history(g.history.position + 1)
end
table.insert(g.history.list, directory)
g.history.size = g.history.size + 1
g.history.position = g.history.position + 1
if g.history.size > o.history_size then
table.remove(g.history.list, 1)
g.history.size = g.history.size - 1
end
end
---@param filepath string
function directory_movement.set_current_file(filepath)
--if we're in idle mode then we want to open the working directory
if filepath == nil then
g.current_file.directory = fb_utils.fix_path( mp.get_property("working-directory", ""), true)
g.current_file.name = nil
g.current_file.path = nil
g.current_file.original_path = nil
return
end
local absolute_path = fb_utils.absolute_path(filepath)
local resolved_path = fb_utils.resolve_directory_mapping(absolute_path)
g.current_file.directory, g.current_file.name = utils.split_path(resolved_path)
g.current_file.original_path = absolute_path
g.current_file.path = resolved_path
if o.cursor_follows_playing_item then cursor.select_playing_item() end
ass.update_ass()
end
--the base function for moving to a directory
---@param directory string
---@param nav_type? NavigationType
---@param store_history? boolean default `true`
---@param parse_properties? ParseProperties
---@return thread
function directory_movement.goto_directory(directory, nav_type, store_history, parse_properties)
local current = g.state.list[g.state.selected]
g.state.directory = directory
if g.state.directory_label then
if nav_type == NavType.DOWN then
g.state.directory_label = g.state.directory_label..(current.label or current.name)
elseif nav_type == NavType.UP then
g.state.directory_label = string.match(g.state.directory_label, "^(.-/+)[^/]+/*$")
end
end
if o.history_size > 0 and store_history == nil or store_history then
directory_movement.append_history(directory)
end
return scanning.rescan(nav_type or NavType.GOTO, nil, parse_properties)
end
---Move the browser to a particular point in the browser history.
---The history is a linear list of visited directories from oldest to newest.
---If the user changes directories while the current history position is not the head of the list,
---any later directories get cleared and the new directory becomes the new head.
---@param pos number The history index to move to. Clamped to [1,history_length]
---@return number|false # The index actually moved to after clamping. Returns -1 if the index was invalid (can occur if history is empty or disabled)
function directory_movement.goto_history(pos)
if type(pos) ~= "number" then return false end
if pos < 1 then pos = 1
elseif pos > g.history.size then pos = g.history.size end
if not g.history.list[pos] then return false end
g.history.position = pos
directory_movement.goto_directory(g.history.list[pos])
return pos
end
--loads the root list
function directory_movement.goto_root()
msg.verbose('jumping to root')
return directory_movement.goto_directory("")
end
--switches to the directory of the currently playing file
function directory_movement.goto_current_dir()
msg.verbose('jumping to current directory')
return directory_movement.goto_directory(g.current_file.directory)
end
--moves up a directory
function directory_movement.up_dir()
if g.state.directory == '' then return end
local cached_parent_dir = g.directory_stack.stack[g.directory_stack.position - 1]
if cached_parent_dir then
g.directory_stack.position = g.directory_stack.position - 1
return directory_movement.goto_directory(cached_parent_dir, NavType.UP)
end
local parent_dir = g.state.directory:match("^(.-/+)[^/]+/*$") or ""
if o.skip_protocol_schemes and parent_dir:find("^(%a[%w+-.]*)://$") then
return directory_movement.goto_root()
end
directory_stack_prepend(parent_dir)
return directory_movement.goto_directory(parent_dir, NavType.UP)
end
--moves down a directory
function directory_movement.down_dir()
local current = g.state.list[g.state.selected]
if not current or not fb_utils.parseable_item(current) then return end
local directory, redirected = fb_utils.get_new_directory(current, g.state.directory)
directory_stack_append(directory)
return directory_movement.goto_directory(directory, redirected and NavType.REDIRECT or NavType.DOWN)
end
--moves backwards through the directory history
function directory_movement.back_history()
msg.debug('moving backwards in history to', g.history.list[g.history.position-1])
if g.history.position == 1 then return end
directory_movement.goto_history(g.history.position - 1)
end
--moves forward through the history
function directory_movement.forwards_history()
msg.debug('moving forwards in history to', g.history.list[g.history.position+1])
if g.history.position == g.history.size then return end
directory_movement.goto_history(g.history.position + 1)
end
return directory_movement
@@ -0,0 +1,210 @@
local mp = require 'mp'
local msg = require 'mp.msg'
local utils = require 'mp.utils'
local g = require 'modules.globals'
local fb_utils = require 'modules.utils'
local cursor = require 'modules.navigation.cursor'
local ass = require 'modules.ass'
local parse_state_API = require 'modules.apis.parse-state'
---@class scanning
local scanning = {}
---@enum NavigationType
local NavType = {
DOWN = 1,
UP = -1,
REDIRECT = 2,
GOTO = 3,
RESCAN = 4,
}
scanning.NavType = NavType
---@param directory_stack? boolean
local function clear_non_adjacent_state(directory_stack)
g.state.directory_label = nil
if directory_stack then
g.directory_stack.stack = {g.state.directory}
g.directory_stack.position = 1
end
end
---parses the given directory or defers to the next parser if nil is returned
---@async
---@param directory string
---@param index number
---@return List?
---@return Opts?
function scanning.choose_and_parse(directory, index)
msg.debug(("finding parser for %q"):format(directory))
---@type Parser, List?, Opts?
local parser, list, opts
local parse_state = g.parse_states[coroutine.running() or ""]
while list == nil and not parse_state.already_deferred and index <= #g.parsers do
parser = g.parsers[index]
if parser:can_parse(directory, parse_state) then
msg.debug("attempting parser:", parser:get_id())
list, opts = parser:parse(directory, parse_state)
end
index = index + 1
end
if not list then return nil, {} end
msg.debug("list returned from:", parser:get_id())
opts = opts or {}
if list then opts.id = opts.id or parser:get_id() end
return list, opts
end
---Sets up the parse_state table and runs the parse operation.
---@async
---@param directory string
---@param parse_state_template ParseStateTemplate
---@return List|nil
---@return Opts
local function run_parse(directory, parse_state_template)
msg.verbose(("scanning files in %q"):format(directory))
---@type ParseStateFields
local parse_state = {
source = parse_state_template.source,
directory = directory,
properties = parse_state_template.properties or {}
}
local co = coroutine.running()
g.parse_states[co] = fb_utils.set_prototype(parse_state, parse_state_API) --[[@as ParseState]]
local list, opts = scanning.choose_and_parse(directory, 1)
if list == nil then return msg.debug("no successful parsers found"), {} end
opts = opts or {}
opts.parser = g.parsers[opts.id]
if not opts.filtered then fb_utils.filter(list) end
if not opts.sorted then fb_utils.sort(list) end
return list, opts
end
---Returns the contents of the given directory using the given parse state.
---If a coroutine has already been used for a parse then create a new coroutine so that
---the every parse operation has a unique thread ID.
---@async
---@param directory string
---@param parse_state ParseStateTemplate
---@return List|nil
---@return Opts
function scanning.scan_directory(directory, parse_state)
local co = fb_utils.coroutine.assert("scan_directory must be executed from within a coroutine - aborting scan "..utils.to_string(parse_state))
if not g.parse_states[co] then return run_parse(directory, parse_state) end
--if this coroutine is already is use by another parse operation then we create a new
--one and hand execution over to that
---@async
local new_co = coroutine.create(function()
fb_utils.coroutine.resume_err(co, run_parse(directory, parse_state))
end)
--queue the new coroutine on the mpv event queue
mp.add_timeout(0, function()
local success, err = coroutine.resume(new_co)
if not success then
fb_utils.traceback(err, new_co)
fb_utils.coroutine.resume_err(co)
end
end)
return g.parse_states[co]:yield()
end
---Sends update requests to the different parsers.
---@async
---@param moving_adjacent? number|boolean
---@param parse_properties? ParseProperties
local function update_list(moving_adjacent, parse_properties)
msg.verbose('opening directory: ' .. g.state.directory)
g.state.selected = 1
g.state.selection = {}
local directory = g.state.directory
local list, opts = scanning.scan_directory(g.state.directory, { source = "browser", properties = parse_properties })
--if the running coroutine isn't the one stored in the state variable, then the user
--changed directories while the coroutine was paused, and this operation should be aborted
if coroutine.running() ~= g.state.co then
msg.verbose(g.ABORT_ERROR.msg)
msg.debug("expected:", g.state.directory, "received:", directory)
return
end
--apply fallbacks if the scan failed
if not list then
msg.warn("could not read directory", g.state.directory)
list, opts = {}, {}
opts.empty_text = g.style.warning..'Error: could not parse directory'
end
g.state.list = list
g.state.parser = opts.parser
--setting custom options from parsers
g.state.directory_label = opts.directory_label
g.state.empty_text = opts.empty_text or g.state.empty_text
--we assume that directory is only changed when redirecting to a different location
--therefore we need to change the `moving_adjacent` flag and clear some state values
if opts.directory then
g.state.directory = opts.directory
moving_adjacent = false
clear_non_adjacent_state(true)
end
if opts.selected_index then
g.state.selected = opts.selected_index or g.state.selected
if g.state.selected > #g.state.list then g.state.selected = #g.state.list
elseif g.state.selected < 1 then g.state.selected = 1 end
end
if moving_adjacent then cursor.select_prev_directory()
else cursor.select_playing_item() end
g.state.prev_directory = g.state.directory
end
---rescans the folder and updates the list.
---@param nav_type? NavigationType
---@param cb? function
---@param parse_properties? ParseProperties
---@return thread # The coroutine for the triggered parse operation. May be aborted early if directory is in the cache.
function scanning.rescan(nav_type, cb, parse_properties)
if nav_type == nil then nav_type = NavType.RESCAN end
--we can only make assumptions about the directory label when moving from adjacent directories
if nav_type == NavType.GOTO or nav_type == NavType.REDIRECT then
clear_non_adjacent_state(nav_type == NavType.GOTO)
end
g.state.empty_text = "~"
g.state.list = {}
cursor.disable_select_mode()
ass.update_ass()
--the directory is always handled within a coroutine to allow addons to
--pause execution for asynchronous operations
---@async
local co = fb_utils.coroutine.queue(function()
update_list(nav_type, parse_properties)
if g.state.empty_text == "~" then g.state.empty_text = "empty directory" end
ass.update_ass()
if cb then fb_utils.coroutine.run(cb) end
end)
g.state.co = co
return co
end
return scanning
@@ -0,0 +1,48 @@
local g = require 'modules.globals'
local directory_movement = require 'modules.navigation.directory-movement'
local fb = require 'modules.apis.fb'
local fb_utils = require 'modules.utils'
local ass = require 'modules.ass'
---@class observers
local observers ={}
---saves the directory and name of the currently playing file
---@param _ string
---@param filepath string
function observers.current_directory(_, filepath)
directory_movement.set_current_file(filepath)
end
---@param _ string
---@param device string
function observers.dvd_device(_, device)
if not device or device == "" then device = '/dev/dvd' end
fb.register_directory_mapping(fb_utils.absolute_path(device), '^dvd://.*', true)
end
---@param _ string
---@param device string
function observers.bd_device(_, device)
if not device or device == '' then device = '/dev/bd' end
fb.register_directory_mapping(fb_utils.absolute_path(device), '^bd://.*', true)
end
---@param _ string
---@param device string
function observers.cd_device(_, device)
if not device or device == '' then device = '/dev/cdrom' end
fb.register_directory_mapping(fb_utils.absolute_path(device), '^cdda://.*', true)
end
---@param property string
---@param alignment string
function observers.osd_align(property, alignment)
if property == 'osd-align-x' then g.ALIGN_X = alignment
elseif property == 'osd-align-y' then g.ALIGN_Y = alignment end
g.style.global = ([[{\an%d}]]):format(g.ASS_ALIGNMENT_MATRIX[g.ALIGN_Y][g.ALIGN_X])
ass.update_ass()
end
return observers
+193
View File
@@ -0,0 +1,193 @@
local utils = require 'mp.utils'
local opt = require 'mp.options'
---@class options
local o = {
--root directories
root = "~/",
--automatically detect windows drives and adds them to the root.
auto_detect_windows_drives = true,
--characters to use as separators
root_separators = ",",
--number of entries to show on the screen at once
num_entries = 20,
--number of directories to keep in the history
history_size = 100,
--wrap the cursor around the top and bottom of the list
wrap = false,
--only show files compatible with mpv
filter_files = true,
--recurses directories concurrently when appending items to the playlist
concurrent_recursion = true,
--maximum number of recursions that can run concurrently
max_concurrency = 16,
--enable custom keybinds
custom_keybinds = true,
custom_keybinds_file = "~~/script-opts/file-browser-keybinds.json",
--blacklist compatible files, it's recommended to use this rather than to edit the
--compatible list directly. A comma separated list of extensions without spaces
extension_blacklist = "",
--add extra file extensions
extension_whitelist = "",
--files with these extensions will be added as additional audio tracks for the current file instead of appended to the playlist
audio_extensions = "mka,dts,dtshd,dts-hd,truehd,true-hd",
--files with these extensions will be added as additional subtitle tracks instead of appended to the playlist
subtitle_extensions = "etf,etf8,utf-8,idx,sub,srt,rt,ssa,ass,mks,vtt,sup,scc,smi,lrc,pgs",
--filter dot directories like .config
--most useful on linux systems
---@type 'auto'|'yes'|'no'
filter_dot_dirs = 'auto',
---@type 'auto'|'yes'|'no'
filter_dot_files = 'auto',
--substitute forward slashes for backslashes when appending a local file to the playlist
--potentially useful on windows systems
substitute_backslash = false,
--interpret backslashes `\` in paths as forward slashes `/`
--this is useful on Windows, which natively uses backslashes.
--As backslashes are valid filename characters in Unix systems this could
--cause mangled paths, though such filenames are rare.
--Use `yes` and `no` to enable/disable. `auto` tries to use the mpv `platform`
--property (mpv v0.36+) to decide. If the property is unavailable it defaults to `yes`.
---@type 'auto'|'yes'|'no'
normalise_backslash = 'auto',
--a directory cache to improve directory reading time,
--enable if it takes a long time to load directories.
--may cause 'ghost' files to be shown that no-longer exist or
--fail to show files that have recently been created.
cache = false,
--this option reverses the behaviour of the alt+ENTER keybind
--when disabled the keybind is required to enable autoload for the file
--when enabled the keybind disables autoload for the file
autoload = false,
--if autoload is triggered by selecting the currently playing file, then
--the current file will have it's watch-later config saved before being closed
--essentially the current file will not be restarted
autoload_save_current = true,
--when opening the browser in idle mode prefer the current working directory over the root
--note that the working directory is set as the 'current' directory regardless, so `home` will
--move the browser there even if this option is set to false
default_to_working_directory = false,
--When opening the browser prefer the directory last opened by a previous mpv instance of file-browser.
--Overrides the `default_to_working_directory` option.
--Requires `save_last_opened_directory` to be true.
--Uses the internal `last-opened-directory` addon.
default_to_last_opened_directory = false,
--Whether to save the last opened directory and the file to save this value in.
save_last_opened_directory = false,
last_opened_directory_file = '~~state/file_browser-last_opened_directory',
--when moving up a directory do not stop on empty protocol schemes like `ftp://`
--e.g. moving up from `ftp://localhost/` will move straight to the root instead of `ftp://`
skip_protocol_schemes = true,
--move the cursor to the currently playing item (if available) when the playing file changes
cursor_follows_playing_item = false,
--Replace the user's home directory with `~/` in the header.
--Uses the internal home-label addon.
home_label = true,
--map optical device paths to their respective file paths,
--e.g. mapping bd:// to the value of the bluray-device property
map_bd_device = true,
map_dvd_device = true,
map_cdda_device = true,
--allows custom icons be set for the folder and cursor
--the `\h` character is a hard space to add padding between the symbol and the text
folder_icon = [[{\p1}m 6.52 0 l 1.63 0 b 0.73 0 0.01 0.73 0.01 1.63 l 0 11.41 b 0 12.32 0.73 13.05 1.63 13.05 l 14.68 13.05 b 15.58 13.05 16.31 12.32 16.31 11.41 l 16.31 3.26 b 16.31 2.36 15.58 1.63 14.68 1.63 l 8.15 1.63{\p0}\h]],
cursor_icon = [[{\p1}m 14.11 6.86 l 0.34 0.02 b 0.25 -0.02 0.13 -0 0.06 0.08 b -0.01 0.16 -0.02 0.28 0.04 0.36 l 3.38 5.55 l 3.38 5.55 3.67 6.15 3.81 6.79 3.79 7.45 3.61 8.08 3.39 8.5l 0.04 13.77 b -0.02 13.86 -0.01 13.98 0.06 14.06 b 0.11 14.11 0.17 14.13 0.24 14.13 b 0.27 14.13 0.31 14.13 0.34 14.11 l 14.11 7.28 b 14.2 7.24 14.25 7.16 14.25 7.07 b 14.25 6.98 14.2 6.9 14.11 6.86{\p0}\h]],
cursor_icon_flipped = [[{\p1}m 0.13 6.86 l 13.9 0.02 b 14 -0.02 14.11 -0 14.19 0.08 b 14.26 0.16 14.27 0.28 14.21 0.36 l 10.87 5.55 l 10.87 5.55 10.44 6.79 10.64 8.08 10.86 8.5l 14.21 13.77 b 14.27 13.86 14.26 13.98 14.19 14.06 b 14.14 14.11 14.07 14.13 14.01 14.13 b 13.97 14.13 13.94 14.13 13.9 14.11 l 0.13 7.28 b 0.05 7.24 0 7.16 0 7.07 b 0 6.98 0.05 6.9 0.13 6.86{\p0}\h]],
--enable addons
addons = true,
addon_directory = "~~/script-modules/file-browser-addons",
--Enables the internal `ls` addon that parses directories using the `ls` commandline tool.
--Allows directory parsing to run concurrently, which prevents the browser from locking up.
--Automatically disables itself on Windows systems.
ls_parser = true,
--Enables the internal `windir` addon that parses directories using the `dir` command in cmd.exe.
--Allows directory parsing to run concurrently, which prevents the browser from locking up.
--Automatically disables itself on non-Windows systems.
windir_parser = true,
--directory to load external modules - currently just user-input-module
module_directory = "~~/script-modules",
--turn the OSC idle screen off and on when opening and closing the browser
toggle_idlescreen = false,
--Set the current open status of the browser in the `file_browser/open` field of the `user-data` property.
--This property is only available in mpv v0.36+.
set_user_data = true,
--Set the current open status of the browser in the `file_browser-open` field of the `shared-script-properties` property.
--This property is deprecated. When it is removed in mpv v0.37 file-browser will automatically ignore this option.
set_shared_script_properties = false,
---@type 'auto'|'left'|'center'|'right'
align_x = 'left',
---@type 'auto'|'top'|'center'|'bottom'
align_y = 'top',
--style settings
format_string_header = [[{\fnMonospace}[%i/%x]%^ %q\N------------------------------------------------------------------]],
format_string_topwrapper = '...',
format_string_bottomwrapper = '...',
font_bold_header = true,
font_opacity_selection_marker = "99",
scaling_factor_base = 1,
scaling_factor_header = 1.4,
scaling_factor_wrappers = 1,
font_name_header = "",
font_name_body = "",
font_name_wrappers = "",
font_name_folder = "",
font_name_cursor = "",
font_colour_header = "00ccff",
font_colour_body = "ffffff",
font_colour_wrappers = "00ccff",
font_colour_cursor = "00ccff",
font_colour_escape_chars = "413eff",
font_colour_multiselect = "fcad88",
font_colour_selected = "fce788",
font_colour_playing = "33ff66",
font_colour_playing_multiselected = "22b547"
}
opt.read_options(o, 'file_browser')
---@diagnostic disable-next-line deprecated
o.set_shared_script_properties = o.set_shared_script_properties and utils.shared_script_property_set
return o
+362
View File
@@ -0,0 +1,362 @@
------------------------------------------------------------------------------------------
---------------------------------File/Playlist Opening------------------------------------
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
local mp = require 'mp'
local msg = require 'mp.msg'
local utils = require 'mp.utils'
local o = require 'modules.options'
local g = require 'modules.globals'
local fb_utils = require 'modules.utils'
local ass = require 'modules.ass'
local cursor = require 'modules.navigation.cursor'
local controls = require 'modules.controls'
local scanning = require 'modules.navigation.scanning'
local movement = require 'modules.navigation.directory-movement'
local state = g.state
---@alias LoadfileFlag 'replace'|'append-play'
---@class LoadOpts
---@field directory string
---@field flag LoadfileFlag
---@field autoload boolean
---@field items_appended number
---@field co thread
---@field concurrency number
---In mpv v0.38 a new index argument was added to the loadfile command.
---For some crazy reason this new argument is placed before the existing options
---argument, breaking any scripts that used it. This function finds the correct index
---for the options argument using the `command-list` property.
---@return integer
local function get_loadfile_options_arg_index()
---@type table[]
local command_list = mp.get_property_native('command-list', {})
for _, command in ipairs(command_list) do
if command.name == 'loadfile' then
for i, arg in ipairs(command.args or {} --[=[@as table[]]=]) do
if arg.name == 'options' then
return i
end
end
end
end
return 3
end
local LEGACY_LOADFILE_SYNTAX = get_loadfile_options_arg_index() == 3
---A wrapper around loadfile to handle the syntax changes introduced in mpv v0.38.
---@param file string
---@param flag string
---@param options? string|table<string,unknown>
---@return boolean
local function legacy_loadfile_wrapper(file, flag, options)
if LEGACY_LOADFILE_SYNTAX then
return mp.command_native({"loadfile", file, flag, options}) ~= nil
else
return mp.command_native({"loadfile", file, flag, -1, options}) ~= nil
end
end
---Adds a file to the playlist and changes the flag to `append-play` in preparation for future items.
---@param file string
---@param opts LoadOpts
---@param mpv_opts? string|table<string,unknown>
local function loadfile(file, opts, mpv_opts)
if o.substitute_backslash and not fb_utils.get_protocol(file) then
file = string.gsub(file, "/", "\\")
end
if opts.flag == "replace" then msg.verbose("Playling file", file)
else msg.verbose("Appending", file, "to the playlist") end
if mpv_opts then
msg.debug('Settings opts on', file, ':', utils.to_string(mpv_opts))
end
if not legacy_loadfile_wrapper(file, opts.flag, mpv_opts) then msg.warn(file) end
if opts.flag == 'replace' and mp.get_property_bool('pause') then mp.set_property_bool('pause', false) end
opts.flag = "append-play"
opts.items_appended = opts.items_appended + 1
end
---@diagnostic disable-next-line no-unknown
local concurrent_loadlist_wrapper
---@alias ConcurrentRefMap table<List|Item,{directory: string?, sublist: List?, recurse: boolean?}>
---This function recursively loads directories concurrently in separate coroutines.
---Results are saved in a tree of tables that allows asynchronous access.
---@async
---@param directory string
---@param load_opts LoadOpts
---@param prev_dirs Set<string>
---@param item_t Item
---@param refs ConcurrentRefMap
---@return boolean?
local function concurrent_loadlist_parse(directory, load_opts, prev_dirs, item_t, refs)
if not refs[item_t] then refs[item_t] = {} end
--prevents infinite recursion from the item.path or opts.directory fields
if prev_dirs[directory] then return end
prev_dirs[directory] = true
local list, list_opts = scanning.scan_directory(directory, { source = 'loadlist' })
if list == g.root then return end
--if we can't parse the directory then append it and hope mpv fares better
if list == nil then
msg.warn("Could not parse", directory, "appending to playlist anyway")
refs[item_t].recurse = false
return
end
directory = list_opts.directory or directory
--we must declare these before we start loading sublists otherwise the append thread will
--need to wait until the whole list is loaded (when synchronous IO is used)
refs[item_t].sublist = list or {}
refs[list] = {directory = directory}
if directory == "" then return end
--launches new parse operations for directories, each in a different coroutine
for _, item in ipairs(list) do
if fb_utils.parseable_item(item) then
fb_utils.coroutine.run(concurrent_loadlist_wrapper, fb_utils.get_new_directory(item, directory), load_opts, prev_dirs, item, refs)
end
end
return true
end
---A wrapper function that ensures the concurrent_loadlist_parse is run correctly.
---@async
---@param directory string
---@param opts LoadOpts
---@param prev_dirs Set<string>
---@param item Item
---@param refs ConcurrentRefMap
function concurrent_loadlist_wrapper(directory, opts, prev_dirs, item, refs)
--ensures that only a set number of concurrent parses are operating at any one time.
--the mpv event queue is seemingly limited to 1000 items, but only async mpv actions like
--command_native_async should use that, events like mp.add_timeout (which coroutine.sleep() uses) should
--be handled enturely on the Lua side with a table, which has a significantly larger maximum size.
while (opts.concurrency > o.max_concurrency) do
fb_utils.coroutine.sleep(0.1)
end
opts.concurrency = opts.concurrency + 1
local success = concurrent_loadlist_parse(directory, opts, prev_dirs, item, refs)
opts.concurrency = opts.concurrency - 1
if not success then refs[item].sublist = {} end
if coroutine.status(opts.co) == "suspended" then fb_utils.coroutine.resume_err(opts.co) end
end
---Recursively appends items to the playlist, acts as a consumer to the previous functions producer;
---If the next directory has not been parsed this function will yield until the parse has completed.
---@async
---@param list List
---@param load_opts LoadOpts
---@param refs ConcurrentRefMap
local function concurrent_loadlist_append(list, load_opts, refs)
local directory = refs[list].directory
for _, item in ipairs(list) do
if not g.sub_extensions[ fb_utils.get_extension(item.name, "") ]
and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ]
then
while fb_utils.parseable_item(item) and (not refs[item] or not refs[item].sublist) do
coroutine.yield()
end
if fb_utils.parseable_item(item) and refs[item] ~= false then
concurrent_loadlist_append(refs[item].sublist, load_opts, refs)
else
loadfile(fb_utils.get_full_path(item, directory), load_opts, item.mpv_options)
end
end
end
end
---Recursive function to load directories serially.
---Returns true if any items were appended to the playlist.
---@async
---@param directory string
---@param load_opts LoadOpts
---@param prev_dirs Set<string>
---@return true|nil
local function custom_loadlist_recursive(directory, load_opts, prev_dirs)
--prevents infinite recursion from the item.path or opts.directory fields
if prev_dirs[directory] then return end
prev_dirs[directory] = true
local list, opts = scanning.scan_directory(directory, { source = "loadlist" })
if list == g.root then return end
--if we can't parse the directory then append it and hope mpv fares better
if list == nil then
msg.warn("Could not parse", directory, "appending to playlist anyway")
loadfile(directory, load_opts)
return true
end
directory = opts.directory or directory
if directory == "" then return end
for _, item in ipairs(list) do
if not g.sub_extensions[ fb_utils.get_extension(item.name, "") ]
and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ]
then
if fb_utils.parseable_item(item) then
custom_loadlist_recursive( fb_utils.get_new_directory(item, directory) , load_opts, prev_dirs)
else
local path = fb_utils.get_full_path(item, directory)
loadfile(path, load_opts, item.mpv_options)
end
end
end
end
---A wrapper for the custom_loadlist_recursive function.
---@async
---@param item Item
---@param opts LoadOpts
local function loadlist(item, opts)
local dir = fb_utils.get_full_path(item, opts.directory)
local num_items = opts.items_appended
if o.concurrent_recursion then
item = fb_utils.copy_table(item)
opts.co = fb_utils.coroutine.assert()
opts.concurrency = 0
---@type List
local v_list = {item}
---@type ConcurrentRefMap
local refs = setmetatable({[v_list] = {directory = opts.directory}}, {__mode = 'k'})
--we need the current coroutine to suspend before we run the first parse operation, so
--we schedule the coroutine to run on the mpv event queue
fb_utils.coroutine.queue(concurrent_loadlist_wrapper, dir, opts, {}, item, refs)
concurrent_loadlist_append(v_list, opts, refs)
else
custom_loadlist_recursive(dir, opts, {})
end
if opts.items_appended == num_items then msg.warn(dir, "contained no valid files") end
end
---Load playlist entries before and after the currently playing file.
---@param path string
---@param opts LoadOpts
local function autoload_dir(path, opts)
if o.autoload_save_current and path == g.current_file.path then
mp.commandv("write-watch-later-config") end
--loads the currently selected file, clearing the playlist in the process
loadfile(path, opts)
local pos = 1
local file_count = 0
for _,item in ipairs(state.list) do
if item.type == "file"
and not g.sub_extensions[ fb_utils.get_extension(item.name, "") ]
and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ]
then
local p = fb_utils.get_full_path(item)
if p == path then pos = file_count
else loadfile( p, opts, item.mpv_options) end
file_count = file_count + 1
end
end
mp.commandv("playlist-move", 0, pos+1)
end
---Runs the loadfile or loadlist command.
---@async
---@param item Item
---@param opts LoadOpts
---@return nil
local function open_item(item, opts)
if fb_utils.parseable_item(item) then
return loadlist(item, opts)
end
local path = fb_utils.get_full_path(item, opts.directory)
if g.sub_extensions[ fb_utils.get_extension(item.name, "") ] then
mp.commandv("sub-add", path, opts.flag == "replace" and "select" or "auto")
elseif g.audio_extensions[ fb_utils.get_extension(item.name, "") ] then
mp.commandv("audio-add", path, opts.flag == "replace" and "select" or "auto")
else
if opts.autoload then autoload_dir(path, opts)
else loadfile(path, opts, item.mpv_options) end
end
end
---Handles the open options as a coroutine.
---Once loadfile has been run we can no-longer guarantee synchronous execution - the state values may change
---therefore, we must ensure that any state values that could be used after a loadfile call are saved beforehand.
---@async
---@param opts LoadOpts
---@return nil
local function open_file_coroutine(opts)
if not state.list[state.selected] then return end
if opts.flag == 'replace' then controls.close() end
--we want to set the idle option to yes to ensure that if the first item
--fails to load then the player has a chance to attempt to load further items (for async append operations)
local idle = mp.get_property("idle", "once")
mp.set_property("idle", "yes")
--handles multi-selection behaviour
if next(state.selection) then
local selection = fb_utils.sort_keys(state.selection)
--reset the selection after
state.selection = {}
cursor.disable_select_mode()
ass.update_ass()
--the currently selected file will be loaded according to the flag
--the flag variable will be switched to append once a file is loaded
for i=1, #selection do
open_item(selection[i], opts)
end
else
local item = state.list[state.selected]
if opts.flag == "replace" then movement.down_dir() end
open_item(item, opts)
end
if mp.get_property("idle") == "yes" then mp.set_property("idle", idle) end
end
--opens the selelected file(s)
local function open_file(flag, autoload)
---@type LoadOpts
local opts = {
flag = flag,
autoload = (autoload ~= o.autoload and flag == "replace"),
directory = state.directory,
items_appended = 0,
concurrency = 0,
co = coroutine.create(open_file_coroutine)
}
fb_utils.coroutine.resume_err(opts.co, opts)
end
---@class playlist
return {
add_files = open_file,
}
@@ -0,0 +1,111 @@
local mp = require 'mp'
local msg = require 'mp.msg'
local utils = require 'mp.utils'
local o = require 'modules.options'
local g = require 'modules.globals'
local fb_utils = require 'modules.utils'
local scanning = require 'modules.navigation.scanning'
---@class script_messages
local script_messages = {}
---Allows other scripts to request directory contents from file-browser.
---@param directory string
---@param response_str string
function script_messages.get_directory_contents(directory, response_str)
---@async
fb_utils.coroutine.run(function()
if not directory then msg.error("did not receive a directory string"); return end
if not response_str then msg.error("did not receive a response string"); return end
directory = mp.command_native({"expand-path", directory}, "") --[[@as string]]
if directory ~= "" then directory = fb_utils.fix_path(directory, true) end
msg.verbose(("recieved %q from 'get-directory-contents' script message - returning result to %q"):format(directory, response_str))
directory = fb_utils.resolve_directory_mapping(directory)
---@class OptsWithVersion: Opts
---@field API_VERSION string?
---@type List|nil, OptsWithVersion|Opts|nil
local list, opts = scanning.scan_directory(directory, { source = "script-message" } )
if opts then opts.API_VERSION = g.API_VERSION end
local list_str, err = fb_utils.format_json_safe(list)
if not list_str then msg.error(err) end
local opts_str, err2 = fb_utils.format_json_safe(opts)
if not opts_str then msg.error(err2) end
mp.commandv("script-message", response_str, list_str or "", opts_str or "")
end)
end
---A helper script message for custom keybinds.
---Substitutes any '=>' arguments for 'script-message'.
---Makes chaining script-messages much easier.
---@param ... string
function script_messages.chain(...)
---@type string[]
local command = table.pack('script-message', ...)
for i, v in ipairs(command) do
if v == '=>' then command[i] = 'script-message' end
end
mp.commandv(table.unpack(command))
end
---A helper script message for custom keybinds.
---Sends a command after the specified delay.
---@param delay string
---@param ... string
---@return nil
function script_messages.delay_command(delay, ...)
local command = table.pack(...)
local success, err = pcall(mp.add_timeout, fb_utils.evaluate_string('return '..delay), function() mp.commandv(table.unpack(command)) end)
if not success then return msg.error(err) end
end
---A helper script message for custom keybinds.
---Sends a command only if the given expression returns true.
---@param condition string
---@param ... string
function script_messages.conditional_command(condition, ...)
local command = table.pack(...)
fb_utils.coroutine.run(function()
if fb_utils.evaluate_string('return '..condition) == true then mp.commandv(table.unpack(command)) end
end)
end
---A helper script message for custom keybinds.
---Extracts lua expressions from the command and evaluates them.
---Expressions must be surrounded by !{}. Another ! before the { will escape the evaluation.
---@param ... string
function script_messages.evaluate_expressions(...)
---@type string[]
local args = table.pack(...)
fb_utils.coroutine.run(function()
for i, arg in ipairs(args) do
args[i] = arg:gsub('(!+)(%b{})', function(lead, expression)
if #lead % 2 == 0 then return string.rep('!', #lead/2)..expression end
---@type any
local eval = fb_utils.evaluate_string('return '..expression:sub(2, -2))
return type(eval) == "table" and utils.to_string(eval) or tostring(eval)
end)
end
mp.commandv(table.unpack(args))
end)
end
---A helper function for custom-keybinds.
---Concatenates the command arguments with newlines and runs the
---string as a statement of code.
---@param ... string
function script_messages.run_statement(...)
local statement = table.concat(table.pack(...), '\n')
fb_utils.coroutine.run(fb_utils.evaluate_string, statement)
end
return script_messages
+60
View File
@@ -0,0 +1,60 @@
local mp = require 'mp'
local o = require 'modules.options'
local g = require 'modules.globals'
local fb_utils = require 'modules.utils'
local fb = require 'modules.apis.fb'
--sets up the compatible extensions list
local function setup_extensions_list()
--setting up subtitle extensions
for ext in fb_utils.iterate_opt(o.subtitle_extensions:lower(), ',') do
g.sub_extensions[ext] = true
g.extensions[ext] = true
end
--setting up audio extensions
for ext in fb_utils.iterate_opt(o.audio_extensions:lower(), ',') do
g.audio_extensions[ext] = true
g.extensions[ext] = true
end
--adding file extensions to the set
for _, ext in ipairs(g.compatible_file_extensions) do
g.extensions[ext] = true
end
--adding extra extensions on the whitelist
for str in fb_utils.iterate_opt(o.extension_whitelist:lower(), ',') do
g.extensions[str] = true
end
--removing extensions that are in the blacklist
for str in fb_utils.iterate_opt(o.extension_blacklist:lower(), ',') do
g.extensions[str] = nil
end
end
--splits the string into a table on the separators
local function setup_root()
for str in fb_utils.iterate_opt(o.root) do
local path = mp.command_native({'expand-path', str}) --[[@as string]]
path = fb_utils.fix_path(path, true)
local temp = {name = path, type = 'dir', label = str, ass = fb_utils.ass_escape(str, true)}
g.root[#g.root+1] = temp
end
if g.PLATFORM == 'windows' then
fb.register_root_item('C:/')
elseif g.PLATFORM ~= nil then
fb.register_root_item('/')
end
end
---@class setup
return {
extensions_list = setup_extensions_list,
root = setup_root,
}
+637
View File
@@ -0,0 +1,637 @@
--------------------------------------------------------------------------------------------------------
-----------------------------------------Utility Functions----------------------------------------------
---------------------------------------Part of the addon API--------------------------------------------
--------------------------------------------------------------------------------------------------------
local mp = require 'mp'
local msg = require 'mp.msg'
local utils = require 'mp.utils'
local o = require 'modules.options'
local g = require 'modules.globals'
local input_loaded, input = pcall(require, 'mp.input')
local user_input_loaded, user_input = pcall(require, 'user-input-module')
--creates a table for the API functions
--adds one metatable redirect to prevent addon authors from accidentally breaking file-browser
---@class fb_utils
local fb_utils = { API_VERSION = g.API_VERSION }
fb_utils.list = {}
fb_utils.coroutine = {}
--implements table.pack if on lua 5.1
if not table.pack then
table.unpack = unpack ---@diagnostic disable-line deprecated
---@diagnostic disable-next-line: duplicate-set-field
function table.pack(...)
local t = {n = select("#", ...), ...}
return t
end
end
---Returns the index of the given item in the table.
---Return -1 if item does not exist.
---@generic T
---@param t T[]
---@param item T
---@param from_index? number
---@return integer
function fb_utils.list.indexOf(t, item, from_index)
for i = from_index or 1, #t, 1 do
if t[i] == item then return i end
end
return -1
end
---Returns whether or not the given table contains an entry that
---causes the given function to evaluate to true.
---@generic T
---@param t T[]
---@param fn fun(v: T, i: number, t: T[]): boolean
---@return boolean
function fb_utils.list.some(t, fn)
for i, v in ipairs(t --[=[@as any[]]=]) do
if fn(v, i, t) then return true end
end
return false
end
---Creates a new table populated with the results of
---calling a provided function on every element in t.
---@generic T
---@generic R
---@param t T[]
---@param fn fun(v: T, i: number, t: T[]): R
---@return R[]
function fb_utils.list.map(t, fn)
local new_t = {}
for i, v in ipairs(t --[=[@as any[]]=]) do
new_t[i] = fn(v, i, t) ---@diagnostic disable-line no-unknown
end
return new_t
end
---Prints an error message and a stack trace.
---Can be passed directly to xpcall.
---@param errmsg string
---@param co? thread A coroutine to grab the stack trace from.
function fb_utils.traceback(errmsg, co)
if co then
msg.warn(debug.traceback(co))
else
msg.warn(debug.traceback("", 2))
end
msg.error(errmsg)
end
---Returns a table that stores the given table t as the __index in its metatable.
---Creates a prototypally inherited table.
---@generic T: table
---@param t T
---@return T
function fb_utils.redirect_table(t)
return setmetatable({}, { __index = t })
end
---Sets the given table `proto` as the `__index` field in table `t`s metatable.
---@generic T: table
---@param t T
---@param proto table
---@return T
function fb_utils.set_prototype(t, proto)
return setmetatable(t, { __index = proto })
end
---Prints an error if a coroutine returns an error.
---Unlike coroutine.resume_err this still returns the results of coroutine.resume().
---@param ... any
---@return boolean
---@return ...
function fb_utils.coroutine.resume_catch(...)
local returns = table.pack(coroutine.resume(...))
if not returns[1] and returns[2] ~= g.ABORT_ERROR then
fb_utils.traceback(returns[2], select(1, ...))
end
return table.unpack(returns, 1, returns.n)
end
---Resumes a coroutine and prints an error if it was not sucessful.
---@param ... any
---@return boolean
function fb_utils.coroutine.resume_err(...)
local success, err = coroutine.resume(...)
if not success and err ~= g.ABORT_ERROR then
fb_utils.traceback(err, select(1, ...))
end
return success
end
---Throws an error if not run from within a coroutine.
---In lua 5.1 there is only one return value which will be nil if run from the main thread.
---In lua 5.2 main will be true if running from the main thread.
---@param err any
---@return thread
function fb_utils.coroutine.assert(err)
local co, main = coroutine.running()
assert(not main and co, err or "error - function must be executed from within a coroutine")
return co
end
---Creates a callback function to resume the current coroutine with the given time limit.
---If the time limit expires the coroutine will be resumed. The first return value will be true
---if the callback was resumed within the time limit and false otherwise.
---If time_limit is falsy then there will be no time limit and there will be no additional return value.
---@param time_limit? number seconds
---@return fun(...)
function fb_utils.coroutine.callback(time_limit)
local co = fb_utils.coroutine.assert("cannot create a coroutine callback for the main thread")
local timer = time_limit and mp.add_timeout(time_limit, function ()
msg.debug("time limit on callback expired")
fb_utils.coroutine.resume_err(co, false)
end)
local function fn(...)
if timer then
if not timer:is_enabled() then return
else timer:kill() end
return fb_utils.coroutine.resume_err(co, true, ...)
end
return fb_utils.coroutine.resume_err(co, ...)
end
return fn
end
---Puts the current coroutine to sleep for the given number of seconds.
---@async
---@param n number
---@return nil
function fb_utils.coroutine.sleep(n)
mp.add_timeout(n, fb_utils.coroutine.callback())
coroutine.yield()
end
---Runs the given function in a coroutine, passing through any additional arguments.
---Does not run the coroutine immediately, instead it queues the coroutine to run when the thread is next idle.
---Returns the coroutine object so that the caller can act on it before it is run.
---@param fn async fun()
---@param ... any
---@return thread
function fb_utils.coroutine.queue(fn, ...)
local co = coroutine.create(fn)
local args = table.pack(...)
mp.add_timeout(0, function() fb_utils.coroutine.resume_err(co, table.unpack(args, 1, args.n)) end)
return co
end
---Runs the given function in a coroutine, passing through any additional arguments.
---This is for triggering an event in a coroutine.
---@param fn async fun()
---@param ... any
function fb_utils.coroutine.run(fn, ...)
local co = coroutine.create(fn)
fb_utils.coroutine.resume_err(co, ...)
end
---Get the full path for the current file.
---@param item Item
---@param dir? string
---@return string
function fb_utils.get_full_path(item, dir)
if item.path then return item.path end
return (dir or g.state.directory)..item.name
end
---Gets the path for a new subdirectory, redirects if the path field is set.
---Returns the new directory path and a boolean specifying if a redirect happened.
---@param item Item
---@param directory string
---@return string new_directory
---@return boolean? redirected `true` if the path was redirected
function fb_utils.get_new_directory(item, directory)
if item.path and item.redirect ~= false then return item.path, true end
if directory == "" then return item.name end
if string.sub(directory, -1) == "/" then return directory..item.name end
return directory.."/"..item.name
end
---Returns the file extension of the given file, or def if there is none.
---@generic T
---@param filename string
---@param def? T
---@return string|T
---@overload fun(filename: string): string|nil
function fb_utils.get_extension(filename, def)
return string.lower(filename):match("%.([^%./]+)$") or def
end
---Returns the protocol scheme of the given url, or def if there is none.
---@generic T
---@param filename string
---@param def T
---@return string|T
---@overload fun(filename: string): string|nil
function fb_utils.get_protocol(filename, def)
return string.lower(filename):match("^(%a[%w+-.]*)://") or def
end
---Formats strings for ass handling.
---This function is based on a similar function from
---https://github.com/mpv-player/mpv/blob/master/player/lua/console.lua#L110.
---@param str string
---@param replace_newline? true|string
---@return string
function fb_utils.ass_escape(str, replace_newline)
if replace_newline == true then replace_newline = "\\\239\187\191n" end
--escape the invalid single characters
str = string.gsub(str, '[\\{}\n]', {
-- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if
-- it isn't followed by a recognised character, so add a zero-width
-- non-breaking space
['\\'] = '\\\239\187\191',
['{'] = '\\{',
['}'] = '\\}',
-- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of
-- consecutive newlines
['\n'] = '\239\187\191\\N',
})
-- Turn leading spaces into hard spaces to prevent ASS from stripping them
str = str:gsub('\\N ', '\\N\\h')
str = str:gsub('^ ', '\\h')
if replace_newline then
str = string.gsub(str, "\\N", replace_newline)
end
return str
end
---Escape lua pattern characters.
---@param str string
---@return string
function fb_utils.pattern_escape(str)
return (string.gsub(str, "([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1"))
end
---Standardises filepaths across systems.
---@param str string
---@param is_directory? boolean
---@return string
function fb_utils.fix_path(str, is_directory)
if str == '' then return str end
if o.normalise_backslash == 'yes' or (o.normalise_backslash == 'auto' and g.PLATFORM == 'windows') then
str = string.gsub(str, [[\]],[[/]])
end
str = str:gsub([[/%./]], [[/]])
if is_directory and str:sub(-1) ~= '/' then str = str..'/' end
return str
end
---Wrapper for mp.utils.join_path to handle protocols.
---@param working string
---@param relative string
---@return string
function fb_utils.join_path(working, relative)
return fb_utils.get_protocol(relative) and relative or utils.join_path(working, relative)
end
---Converts the given path into an absolute path and normalises it using fb_utils.fix_path.
---@param path string
---@return string
function fb_utils.absolute_path(path)
local absolute_path = fb_utils.join_path(mp.get_property('working-directory', ''), path)
return fb_utils.fix_path(absolute_path)
end
---Sorts the table lexicographically ignoring case and accounting for leading/non-leading zeroes.
---The number format functionality was proposed by github user twophyro, and was presumably taken
---from here: http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua.
---@param t List
---@return List
function fb_utils.sort(t)
local function padnum(n, d)
return #d > 0 and ("%03d%s%.12f"):format(#n, n, tonumber(d) / (10 ^ #d))
or ("%03d%s"):format(#n, n)
end
--appends the letter d or f to the start of the comparison to sort directories and folders as well
---@type [string,Item][]
local tuples = {}
for i, f in ipairs(t) do
tuples[i] = {f.type:sub(1, 1) .. (f.label or f.name):lower():gsub("0*(%d+)%.?(%d*)", padnum), f}
end
table.sort(tuples, function(a, b)
-- pretty sure that `#b[2] < #a[2]` does not do anything as they are both Item tables and not strings or arrays
return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1]
end)
for i, tuple in ipairs(tuples) do t[i] = tuple[2] end
return t
end
---@param dir string
---@return boolean
function fb_utils.valid_dir(dir)
if o.filter_dot_dirs == 'yes' or o.filter_dot_dirs == 'auto' and g.PLATFORM ~= 'windows' then
return string.sub(dir, 1, 1) ~= "."
end
return true
end
---@param file string
---@return boolean
function fb_utils.valid_file(file)
if o.filter_dot_files == 'yes' or o.filter_dot_files == 'auto' and g.PLATFORM ~= 'windows' then
if string.sub(file, 1, 1) == "." then return false end
end
if o.filter_files and not g.extensions[ fb_utils.get_extension(file, "") ] then return false end
return true
end
---Returns whether or not the item can be parsed.
---@param item Item
---@return boolean
function fb_utils.parseable_item(item)
return item.type == "dir" or g.parseable_extensions[fb_utils.get_extension(item.name, "")]
end
---Takes a directory string and resolves any directory mappings,
---returning the resolved directory.
---@param path string
---@return string
function fb_utils.resolve_directory_mapping(path)
if not path then return path end
for mapping, target in pairs(g.directory_mappings) do
local start, finish = string.find(path, mapping)
if start then
msg.debug('mapping', mapping, 'found for', path, 'changing to', target)
-- if the mapping is an exact match then return the target as is
if finish == #path then return target end
-- else make sure the path is correctly formatted
target = fb_utils.fix_path(target, true)
return (string.gsub(path, mapping, target))
end
end
return path
end
---Removes items and folders from the list that fail the configured filters.
---@param t List
---@return List
function fb_utils.filter(t)
local max = #t
local top = 1
for i = 1, max do
local temp = t[i]
t[i] = nil
if ( temp.type == "dir" and fb_utils.valid_dir(temp.label or temp.name) ) or
( temp.type == "file" and fb_utils.valid_file(temp.label or temp.name) )
then
t[top] = temp
top = top+1
end
end
return t
end
---Returns a string iterator that uses the root separators.
---@param str any
---@param separators? string Override the root separators.
---@return fun():(string, ...)
function fb_utils.iterate_opt(str, separators)
return string.gmatch(str, "([^"..fb_utils.pattern_escape(separators or o.root_separators).."]+)")
end
---Sorts a table into an array of selected items in the correct order.
---If a predicate function is passed, then the item will only be added to
---the table if the function returns true.
---@param t Set<number>
---@param include_item? fun(item: Item): boolean
---@return Item[]
function fb_utils.sort_keys(t, include_item)
---@class Ref
---@field item Item
---@field index number
---@type Ref[]
local keys = {}
for k in pairs(t) do
local item = g.state.list[k]
if not include_item or include_item(item) then
keys[#keys+1] = {
item = item,
index = k,
}
end
end
table.sort(keys, function(a,b) return a.index < b.index end)
return fb_utils.list.map(keys, function(ref) return ref.item end)
end
---Uses a loop to get the length of an array. The `#` operator is undefined if there
---are gaps in the array, this ensures there are none as expected by the mpv node function.
---@param t any[]
---@return integer
local function get_length(t)
local i = 1
while t[i] do i = i+1 end
return i - 1
end
---Recursively removes elements of the table which would cause
---utils.format_json to throw an error.
---@generic T
---@param t T
---@return T
local function json_safe_recursive(t)
if type(t) ~= "table" then return t end
local array_length = get_length(t)
local isarray = array_length > 0
for key, value in pairs(t --[[@as table<any,any>]]) do
local ktype = type(key)
local vtype = type(value)
if vtype ~= "userdata" and vtype ~= "function" and vtype ~= "thread"
and (( isarray and ktype == "number" and key <= array_length)
or (not isarray and ktype == "string"))
then
---@diagnostic disable-next-line no-unknown
t[key] = json_safe_recursive(t[key])
elseif key then
---@diagnostic disable-next-line no-unknown
t[key] = nil
if isarray then array_length = get_length(t) end
end
end
return t
end
---Formats a table into a json string but ensures there are no invalid datatypes inside the table first.
---@param t any
---@return string|nil
---@return string|nil err
function fb_utils.format_json_safe(t)
--operate on a copy of the table to prevent any data loss in the original table
t = json_safe_recursive(fb_utils.copy_table(t))
local success, result, err = pcall(utils.format_json, t)
if success then return result, err
else return nil, result end
end
---Evaluates and runs the given string in both Lua 5.1 and 5.2.
---Provides the mpv modules and the fb module to the string.
---@param str string
---@param chunkname? string Used for error reporting.
---@param custom_env? table A custom environment that shadows the default environment.
---@param env_defaults? boolean Load lua defaults in environment, as well as mpv and file-browser modules. Defaults to `true`.
---@return unknown
function fb_utils.evaluate_string(str, chunkname, custom_env, env_defaults)
---@type table
local env
if env_defaults ~= false then
---@type table
env = fb_utils.redirect_table(_G)
env.mp = fb_utils.redirect_table(mp)
env.msg = fb_utils.redirect_table(msg)
env.utils = fb_utils.redirect_table(utils)
env.fb = fb_utils.redirect_table(require 'file-browser')
env.input = input_loaded and fb_utils.redirect_table(input)
env.user_input = user_input_loaded and fb_utils.redirect_table(user_input)
env = fb_utils.set_prototype(custom_env or {}, env)
else
env = custom_env or {}
end
---@type function, any
local chunk, err
if setfenv then ---@diagnostic disable-line deprecated
chunk, err = loadstring(str, chunkname) ---@diagnostic disable-line deprecated
if chunk then setfenv(chunk, env) end ---@diagnostic disable-line deprecated
else
chunk, err = load(str, chunkname, 't', env) ---@diagnostic disable-line redundant-parameter
end
if not chunk then
msg.warn('failed to load string:', str)
msg.error(err)
chunk = function() return nil end
end
return chunk()
end
---Copies a table without leaving any references to the original.
---Uses a structured clone algorithm to maintain cyclic references.
---@generic T
---@param t T
---@param references table<table,table>
---@param depth number
---@return T
local function copy_table_recursive(t, references, depth)
if type(t) ~= "table" or depth == 0 then return t end
if references[t] then return references[t] end
local copy = setmetatable({}, { __original = t })
references[t] = copy
for key, value in pairs(t --[[@as table<any,any>]]) do
key = copy_table_recursive(key, references, depth - 1)
copy[key] = copy_table_recursive(value, references, depth - 1) ---@diagnostic disable-line no-unknown
end
return copy
end
---A wrapper around copy_table to provide the reference table.
---@generic T
---@param t T
---@param depth? number
---@return T
function fb_utils.copy_table(t, depth)
--this is to handle cyclic table references
return copy_table_recursive(t, {}, depth or math.huge)
end
---@alias Replacer fun(item: Item, s: State): (string|number|nil)
---@alias ReplacerTable table<string,Replacer>
---functions to replace custom-keybind codes
---@type ReplacerTable
fb_utils.code_fns = {
["%"] = function() return "%" end,
f = function(item, s) return item and fb_utils.get_full_path(item, s.directory) or "" end,
n = function(item, s) return item and (item.label or item.name) or "" end,
i = function(item, s)
local i = fb_utils.list.indexOf(s.list, item)
if #s.list == 0 then return 0 end
return ('%0'..math.ceil(math.log10(#s.list))..'d'):format(i ~= -1 and i or 0) ---@diagnostic disable-line deprecated
end,
j = function (item, s)
return fb_utils.list.indexOf(s.list, item) ~= -1 and math.abs(fb_utils.list.indexOf( fb_utils.sort_keys(s.selection) , item)) or 0
end,
x = function(_, s) return #s.list or 0 end,
p = function(_, s) return s.directory or "" end,
q = function(_, s) return s.directory == '' and 'ROOT' or s.directory_label or s.directory or "" end,
d = function(_, s) return (s.directory_label or s.directory):match("([^/]+)/?$") or "" end,
r = function(_, s) return s.parser.keybind_name or s.parser.name or "" end,
}
---Programatically creates a pattern that matches any key code.
---This will result in some duplicates but that shouldn't really matter.
---@param codes ReplacerTable
---@return string
function fb_utils.get_code_pattern(codes)
---@type string
local CUSTOM_KEYBIND_CODES = ""
for key in pairs(codes) do CUSTOM_KEYBIND_CODES = CUSTOM_KEYBIND_CODES..key:lower()..key:upper() end
for key in pairs((getmetatable(codes) or {}).__index or {} --[[@as ReplacerTable]]) do
---@type string
CUSTOM_KEYBIND_CODES = CUSTOM_KEYBIND_CODES..key:lower()..key:upper()
end
return('%%%%([%s])'):format(fb_utils.pattern_escape(CUSTOM_KEYBIND_CODES))
end
---Substitutes codes in the given string for other substrings.
---@param str string
---@param overrides? ReplacerTable Replacer functions for additional characters to match to after `%` characters.
---@param item? Item Uses the currently selected item if nil.
---@param state? State Uses the global state if nil.
---@param modifier_fn? fun(new_str: string, code: string): string given the replacement substrings before they are placed in the main string
--- (the return value is the new replacement string).
---@return string
function fb_utils.substitute_codes(str, overrides, item, state, modifier_fn)
local replacers = overrides and setmetatable(fb_utils.copy_table(overrides), {__index = fb_utils.code_fns}) or fb_utils.code_fns
item = item or g.state.list[g.state.selected]
state = state or g.state
return (string.gsub(str, fb_utils.get_code_pattern(replacers), function(code)
---@type string|number|nil
local result
local replacer = replacers[code]
if type(replacer) == "string" then
result = replacer
--encapsulates the string if using an uppercase code
elseif not replacer then
local lower_fn = replacers[code:lower()]
if not lower_fn then return end
result = string.format("%q", lower_fn(item, state))
else
result = replacer(item, state)
end
if result and modifier_fn then return modifier_fn(tostring(result), code) end
return result
end))
end
return fb_utils