init
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user