first commit

This commit is contained in:
2025-06-14 20:26:14 +02:00
commit 1edfd60dbd
351 changed files with 34592 additions and 0 deletions

View File

@@ -0,0 +1,174 @@
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 = require 'modules.apis.fb'
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
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
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
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
--gives each addon custom debug messages
addon_environment.package = fb_utils.redirect_table(addon_environment.package)
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
addon_environment.require = function(module)
if module == "mp.msg" then return msg_module end
return require(module)
end
local chunk, err
if setfenv then
--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)
else
chunk, err = _G.loadfile(path, "bt", addon_environment)
if not chunk then return msg.error(err) end
end
local success, result = xpcall(chunk, fb_utils.traceback)
return success and result or nil
end
--setup an internal or external parser
local function setup_parser(parser, file)
parser = setmetatable(parser, { __index = parser_API })
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
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)
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) do
setup_parser(parser, file)
end
end
--loading external addons
local function load_addons(directory)
directory = fb_utils.fix_path(directory, true)
local files = utils.readdir(directory)
if not files then error("could not read addon directory") end
for _, file in ipairs(files) do
setup_addon(file, directory..file)
end
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
local function load_internal_parsers()
local internal_addon_dir = mp.get_script_directory()..'/modules/parsers/'
load_addons(internal_addon_dir)
end
local function load_external_addons()
local addon_dir = mp.command_native({"expand-path", o.addon_directory..'/'})
load_addons(addon_dir)
end
return {
check_api_version = check_api_version,
load_internal_parsers = load_internal_parsers,
load_external_addons = load_external_addons
}

View File

@@ -0,0 +1,136 @@
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 cache = require 'modules.cache'
local controls = require 'modules.controls'
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.rescan = scanning.rescan
fb.browse_directory = controls.browse_directory
function fb.clear_cache()
cache:clear()
end
--a wrapper around scan_directory for addon API
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
function fb.register_parseable_extension(ext)
g.parseable_extensions[string.lower(ext)] = true
end
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
function fb.add_default_extension(ext)
table.insert(g.compatible_file_extensions, ext)
end
--add item to root at position pos
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
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
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
--the priority variable is a number that specifies the insertion location
--a lower priority is placed higher in the list and the default is 100
function fb.register_root_item(item, priority)
msg.verbose('registering root item:', utils.to_string(item))
if type(item) == 'string' then
item = {name = item}
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
item._priority = priority
for i, v in ipairs(g.root) do
if (v._priority 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
function fb.get_script_opts() return fb.copy_table(o) end
function fb.get_opt(key) return o[key] 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_dvd_device() return g.dvd_device 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.set_empty_text(str)
g.state.empty_text = str
fb.redraw()
end
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
return fb

View File

@@ -0,0 +1,32 @@
local msg = require 'mp.msg'
local g = require 'modules.globals'
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
function parse_state_API:yield(...)
local co = coroutine.running()
local is_browser = co == g.state.co
if self.source == "browser" and not is_browser then
msg.error("current coroutine does not match browser's expected coroutine - did you unsafely yield before this?")
error("current coroutine does not match browser's expected coroutine - aborting the parse")
end
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 unpack(result, 1, result.n)
end
--checks if the current coroutine is the one handling the browser's request
function parse_state_API:is_coroutine_current()
return coroutine.running() == g.state.co
end
return parse_state_API

View File

@@ -0,0 +1,25 @@
local msg = require 'mp.msg'
local g = require 'modules.globals'
local scanning = require 'modules.navigation.scanning'
local fb = require 'modules.apis.fb'
local parser_api = setmetatable({}, { __index = fb })
function parser_api:get_index() return g.parsers[self].index end
function parser_api:get_id() return g.parsers[self].id end
--a wrapper that passes the parsers priority value if none other is specified
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
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

View File

@@ -0,0 +1,175 @@
--------------------------------------------------------------------------------------------------------
-----------------------------------------List Formatting------------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
local utils = require 'mp.utils'
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
local function draw()
ass:update()
end
local function remove()
ass:remove()
end
local string_buffer = {}
--appends the entered text to the overlay
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
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))
else
return g.current_file.path == full_path
or (alt_path and g.current_file.path == alt_path)
end
end
local ass_cache = setmetatable({}, {__mode = 'k'})
-- escape ass values and replace newlines
local function ass_escape(str)
if ass_cache[str] then return ass_cache[str] end
local escaped = fb_utils.ass_escape(str, true)
ass_cache[str] = escaped
return escaped
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)
local dir_name = state.directory_label or state.directory
if dir_name == "" then dir_name = "ROOT" end
append(style.header)
append(fb_utils.substitute_codes(o.format_string_header, nil, nil, nil, ass_escape))
newline()
if #state.list < 1 then
append(state.empty_text)
flush_buffer()
draw()
return
end
local start = 1
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
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
-- these are the number values to place into the wrappers
local wrapper_overrides = {['<'] = tostring(start-1), ['>'] = tostring(#state.list-finish)}
--adding a header to show there are items above in the list
if o.format_string_topwrapper ~= '' and start > 1 then
append(style.footer_header, fb_utils.substitute_codes(o.format_string_topwrapper, wrapper_overrides, nil, nil, ass_escape))
newline()
end
for i=start, finish do
local v = state.list[i]
local playing_file = highlight_entry(v)
append(style.body)
--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
append(o.cursor_icon, "\\h", style.body)
else
append(g.style.indent, o.cursor_icon, "\\h", style.body)
end
--sets the selection colour scheme
local multiselected = state.selection[i]
--sets the colour for the item
local function set_colour()
if multiselected then append(style.multiselect)
elseif i == state.selected then append(style.selected) end
if playing_file then append( multiselected and style.playing_selected or style.playing) end
end
set_colour()
--sets the folder icon
if v.type == 'dir' then
append(style.folder, o.folder_icon, "\\h", style.body)
set_colour()
end
--adds the actual name of the item
append(v.ass or ass_escape(v.label or v.name))
newline()
end
if o.format_string_bottomwrapper ~= '' and overflow then
append(style.footer_header)
append(fb_utils.substitute_codes(o.format_string_bottomwrapper, wrapper_overrides, nil, nil, ass_escape))
end
flush_buffer()
draw()
end
return {
update_ass = update_ass,
highlight_entry = highlight_entry,
draw = draw,
remove = remove,
}

View File

@@ -0,0 +1,139 @@
--------------------------------------------------------------------------------------------------------
--------------------------------------Cache Implementation----------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
local msg = require 'mp.msg'
local utils = require 'mp.utils'
local g = require 'modules.globals'
local fb_utils = require 'modules.utils'
local function get_keys(t)
local keys = {}
for key in pairs(t) do
table.insert(keys, key)
end
return keys
end
local cache = {
cache = setmetatable({}, {__mode = 'kv'}),
traversal_stack = {},
history = {},
cached_values = {
"directory", "directory_label", "list", "selected", "selection", "parser", "empty_text", "co"
},
dangling_refs = {},
}
function cache:print_debug_info()
local cache_keys = get_keys(self.cache)
msg.verbose('Printing cache debug info')
msg.verbose('cache size:', #cache_keys)
msg.debug(utils.to_string(cache_keys))
msg.trace(utils.to_string(self.cache[cache_keys[#cache_keys]]))
msg.verbose('traversal_stack size:', #self.traversal_stack)
msg.debug(utils.to_string(fb_utils.list.map(self.traversal_stack, function(ref) return ref.directory end)))
msg.verbose('history size:', #self.history)
msg.debug(utils.to_string(fb_utils.list.map(self.history, function(ref) return ref.directory end)))
end
function cache:replace_dangling_refs(directory, ref)
for _, v in ipairs(self.traversal_stack) do
if v.directory == directory then
v.ref = ref
self.dangling_refs[directory] = nil
end
end
for _, v in ipairs(self.history) do
if v.directory == directory then
v.ref = ref
self.dangling_refs[directory] = nil
end
end
end
function cache:add_current_state()
local directory = g.state.directory
if directory == nil then return end
local t = self.cache[directory] or {}
for _, value in ipairs(self.cached_values) do
t[value] = g.state[value]
end
self.cache[directory] = t
if self.dangling_refs[directory] then
self:replace_dangling_refs(directory, t)
end
end
-- Creates a reference to the cache of a particular directory to prevent it
-- from being garbage collected.
function cache:get_cache_ref(directory)
return {
directory = directory,
ref = self.cache[directory],
}
end
function cache:append_history()
self:add_current_state()
local history_size = #self.history
-- We don't want to have the same directory in the history over and over again.
if history_size > 0 and self.history[history_size].directory == g.state.directory then return end
table.insert(self.history, self:get_cache_ref(g.state.directory))
if (history_size + 1) > 100 then table.remove(self.history, 1) end
end
function cache:in_cache(directory)
return self.cache[directory] ~= nil
end
function cache:apply(directory)
directory = directory or g.state.directory
local t = self.cache[directory]
if not t then return false end
msg.verbose('applying cache for', directory)
for _, value in ipairs(self.cached_values) do
msg.debug('setting', value, 'to', t[value])
g.state[value] = t[value]
end
return true
end
function cache:push()
local stack_size = #self.traversal_stack
if stack_size > 0 and self.traversal_stack[stack_size].directory == g.state.directory then return end
table.insert(self.traversal_stack, self:get_cache_ref(g.state.directory))
end
function cache:pop()
table.remove(self.traversal_stack)
end
function cache:clear_traversal_stack()
self.traversal_stack = {}
end
function cache:clear()
self.cache = setmetatable({}, {__mode = 'kv'})
for _, v in ipairs(self.traversal_stack) do
v.ref = nil
self.dangling_refs[v.directory] = true
end
for _, v in ipairs(self.history) do
v.ref = nil
self.dangling_refs[v.directory] = true
end
end
return cache

View File

@@ -0,0 +1,90 @@
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'
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
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
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
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}) or ''
-- 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)
if open_browser then controls.open() end
return co
end
return controls

View File

@@ -0,0 +1,115 @@
--------------------------------------------------------------------------------------------------------
------------------------------------------Variable Setup------------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
local mp = require 'mp'
local globals = {}
local o = require 'modules.options'
--sets the version for the file-browser API
globals.API_VERSION = "1.7.0"
--gets the current platform (only works in mpv v0.36+)
globals.PLATFORM = mp.get_property_native('platform')
--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
globals.style = {
global = o.alignment == 0 and "" or ([[{\an%d}]]):format(o.alignment),
-- 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),
--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),
}
globals.state = {
list = {},
selected = 1,
hidden = true,
flag_update = false,
keybinds = nil,
parser = nil,
directory = nil,
directory_label = nil,
prev_directory = "",
co = nil,
multiselect_start = nil,
initial_selection = nil,
selection = {}
}
--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
globals.parse_states = setmetatable({}, { __mode = "k"})
globals.extensions = {}
globals.sub_extensions = {}
globals.audio_extensions = {}
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.
globals.directory_mappings = {}
globals.current_file = {
directory = nil,
name = nil,
path = nil,
original_path = nil,
}
globals.root = {}
--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"
}
globals.ABORT_ERROR = {
msg = "browser is no longer waiting for list - aborting parse"
}
return globals

View File

@@ -0,0 +1,308 @@
------------------------------------------------------------------------------------------
----------------------------------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'
local cache = require 'modules.cache'
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},
{'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() cache:clear(); 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
local top_level_keys = {}
--format the item string for either single or multiple items
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'
--substitutes the key codes for the
local function substitute_codes(str, cmd, items, state)
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
local function format_command_table(cmd, items, state)
local copy = {}
for i = 1, #cmd.command do
copy[i] = {}
for j = 1, #cmd.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
--key.command must be an array of command tables compatible with mp.command_native
--items must be an array of multiple items (when multi-type ~= concat the array will be 1 long)
local function run_custom_command(cmd, items, state)
local custom_cmds = cmd.codes and format_command_table(cmd, items, state) or cmd.command
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)
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
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
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
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
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
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
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
--we'll always save the keybinds as either an array of command arrays or a function
if type(keybind.command) == "table" and type(keybind.command[1]) ~= "table" then
keybind.command = {keybind.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()
if not o.custom_keybinds and not o.addons then return end
--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
if o.addons then
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
end
--loads custom keybinds from file-browser-keybinds.json
if o.custom_keybinds then
local path = mp.command_native({"expand-path", "~~/script-opts"}).."/file-browser-keybinds.json"
local custom_keybinds, err = io.open( path )
if not custom_keybinds then return error(err) 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) do
keybind.name = "custom/"..(keybind.name or tostring(i))
insert_custom_keybind(keybind)
end
end
end
return {
setup_keybinds = setup_keybinds,
}

View File

@@ -0,0 +1,121 @@
--------------------------------------------------------------------------------------------------------
--------------------------------Scroll/Select Implementation--------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
local g = require 'modules.globals'
local fb_utils = require 'modules.utils'
local ass = require 'modules.ass'
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]
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()
if g.state.prev_directory:find(g.state.directory, 1, true) == 1 then
local i = 1
while (g.state.list[i] and fb_utils.parseable_item(g.state.list[i])) do
if g.state.prev_directory:find(fb_utils.get_full_path(g.state.list[i]), 1, true) then
g.state.selected = i
return
end
i = i+1
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

View File

@@ -0,0 +1,86 @@
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 cache = require 'modules.cache'
local scanning = require 'modules.navigation.scanning'
local fb_utils = require 'modules.utils'
local directory_movement = {}
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
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 not g.state.hidden then ass.update_ass()
else g.state.flag_update = true end
end
--the base function for moving to a directory
function directory_movement.goto_directory(directory, moving_adjacent)
-- update cache to the lastest state values before changing the current directory
cache:add_current_state()
local current = g.state.list[g.state.selected]
g.state.directory = directory
if g.state.directory_label then
if moving_adjacent == 1 then
g.state.directory_label = g.state.directory_label..(current.label or current.name)
elseif moving_adjacent == -1 then
g.state.directory_label = string.match(g.state.directory_label, "^(.-/+)[^/]+/*$")
end
end
return scanning.rescan(moving_adjacent or false)
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()
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
return directory_movement.goto_directory(parent_dir, -1)
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)
return directory_movement.goto_directory(directory, not redirected and 1)
end
return directory_movement

View File

@@ -0,0 +1,182 @@
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 cache = require 'modules.cache'
local cursor = require 'modules.navigation.cursor'
local ass = require 'modules.ass'
local parse_state_API = require 'modules.apis.parse-state'
local function clear_non_adjacent_state()
g.state.directory_label = nil
cache:clear_traversal_stack()
end
--parses the given directory or defers to the next parser if nil is returned
local function choose_and_parse(directory, index)
msg.debug(("finding parser for %q"):format(directory))
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
local function run_parse(directory, parse_state)
msg.verbose(("scanning files in %q"):format(directory))
parse_state.directory = directory
local co = coroutine.running()
g.parse_states[co] = setmetatable(parse_state, { __index = parse_state_API })
local list, opts = choose_and_parse(directory, 1)
if list == nil then return msg.debug("no successful parsers found") end
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
local function parse_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
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
local function update_list(moving_adjacent)
msg.verbose('opening directory: ' .. g.state.directory)
g.state.selected = 1
g.state.selection = {}
--loads the current directry from the cache to save loading time
if cache:in_cache(g.state.directory) then
msg.verbose('found directory in cache')
cache:apply(g.state.directory)
g.state.prev_directory = g.state.directory
return
end
local directory = g.state.directory
local list, opts = parse_directory(g.state.directory, { source = "browser" })
--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 and cache:in_cache(g.state.prev_directory) then
--switches settings back to the previously opened directory
--to the user it will be like the directory never changed
msg.warn("could not read directory", g.state.directory)
cache:apply(g.state.prev_directory)
return
elseif not list then
--opens the root instead
msg.warn("could not read directory", g.state.directory, "redirecting to root")
list, opts = parse_directory("", { source = "browser" })
-- sets the directory redirect flag
opts.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()
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
--returns the coroutine for the new parse operation
local function rescan(moving_adjacent)
if moving_adjacent == nil then moving_adjacent = 0 end
--we can only make assumptions about the directory label when moving from adjacent directories
if not moving_adjacent then clear_non_adjacent_state() 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
g.state.co = fb_utils.coroutine.queue(function()
update_list(moving_adjacent)
if g.state.empty_text == "~" then g.state.empty_text = "empty directory" end
cache:append_history()
if type(moving_adjacent) == 'number' and moving_adjacent < 0 then cache:pop()
else cache:push() end
if not cache.traversal_stack[1] then cache:push() end
ass.update_ass()
end)
return g.state.co
end
return {
rescan = rescan,
scan_directory = parse_directory,
choose_and_parse = choose_and_parse,
}

View File

@@ -0,0 +1,28 @@
local directory_movement = require 'modules.navigation.directory-movement'
local fb = require 'modules.apis.fb'
local fb_utils = require 'modules.utils'
local observers ={}
--saves the directory and name of the currently playing file
function observers.current_directory(_, filepath)
directory_movement.set_current_file(filepath)
end
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
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
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
return observers

View File

@@ -0,0 +1,147 @@
local utils = require 'mp.utils'
local opt = require 'mp.options'
local o = {
--root directories
root = "~/",
--characters to use as separators
root_separators = ",;",
--number of entries to show on the screen at once
num_entries = 20,
--wrap the cursor around the top and bottom of the list
wrap = false,
--only show files compatible with mpv
filter_files = true,
--experimental feature that recurses directories concurrently when
--appending items to the playlist
concurrent_recursion = false,
--maximum number of recursions that can run concurrently
max_concurrency = 16,
--enable custom keybinds
custom_keybinds = false,
--blacklist compatible files, it's recommended to use this rather than to edit the
--compatible list directly. A semicolon 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
filter_dot_dirs = false,
filter_dot_files = false,
--substitude 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`.
normalise_backslash = 'auto',
--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 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,
--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]],
--enable addons
addons = false,
addon_directory = "~~/script-modules/file-browser-addons",
--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,
--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
--set to 0 to use the default mpv osd-align options
alignment = 7,
--style settings
format_string_header = '%q\\N----------------------------------------------------',
format_string_topwrapper = '%< item(s) above\\N',
format_string_bottomwrapper = '\\N%> item(s) remaining',
font_bold_header = true,
font_opacity_selection_marker = "99",
scaling_factor_base = 1,
scaling_factor_header = 1.4,
scaling_factor_wrappers = 0.64,
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_multiselect = "fcad88",
font_colour_selected = "fce788",
font_colour_playing = "33ff66",
font_colour_playing_multiselected = "22b547"
}
opt.read_options(o, 'file_browser')
o.set_shared_script_properties = o.set_shared_script_properties and utils.shared_script_property_set
return o

View File

@@ -0,0 +1,44 @@
-- 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 object for native filesystems
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')
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

View File

@@ -0,0 +1,25 @@
local g = require 'modules.globals'
--parser object for the root
--not inserted to the parser list as it has special behaviour
--it does get added to parsers under its ID to prevent confusing duplicates
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

View File

@@ -0,0 +1,292 @@
------------------------------------------------------------------------------------------
---------------------------------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
-- 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.
local function get_loadfile_options_arg_index()
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 {}) 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.
local function legacy_loadfile_wrapper(file, flag, options)
if LEGACY_LOADFILE_SYNTAX then
return mp.command_native({"loadfile", file, flag, options})
else
return mp.command_native({"loadfile", file, flag, -1, options})
end
end
--adds a file to the playlist and changes the flag to `append-play` in preparation
--for future items
local function loadfile(file, opts, mpv_opts)
if o.substitute_backslash and not fb_utils.get_protocol(file) then
file = file:gsub("/", "\\")
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
opts.flag = "append-play"
opts.items_appended = opts.items_appended + 1
end
--this function recursively loads directories concurrently in separate coroutines
--results are saved in a tree of tables that allows asynchronous access
local function concurrent_loadlist_parse(directory, load_opts, prev_dirs, item_t)
--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")
item_t.type = "file"
return
end
directory = list_opts.directory or directory
if directory == "" then return end
--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)
item_t._sublist = list or {}
list._directory = directory
--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)
end
end
return true
end
--a wrapper function that ensures the concurrent_loadlist_parse is run correctly
function concurrent_loadlist_wrapper(directory, opts, prev_dirs, item)
--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)
opts.concurrency = opts.concurrency - 1
if not success then 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
local function concurrent_loadlist_append(list, load_opts)
local directory = 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 (not item._sublist and fb_utils.parseable_item(item)) do
coroutine.yield()
end
if fb_utils.parseable_item(item) then
concurrent_loadlist_append(item._sublist, load_opts)
else
loadfile(fb_utils.get_full_path(item, directory), load_opts, item.mpv_options)
end
end
end
end
--recursive function to load directories using the script custom parsers
--returns true if any items were appended to the playlist
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.flag)
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
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
--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
mp.add_timeout(0, function()
fb_utils.coroutine.run(concurrent_loadlist_wrapper, dir, opts, {}, item)
end)
concurrent_loadlist_append({item, _directory = opts.directory}, opts)
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
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
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
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)
fb_utils.coroutine.run(open_file_coroutine, {
flag = flag,
autoload = (autoload ~= o.autoload and flag == "replace"),
directory = state.directory,
items_appended = 0
})
end
return {
add_files = open_file,
}

View File

@@ -0,0 +1,93 @@
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'
local script_messages = {}
--allows other scripts to request directory contents from file-browser
function script_messages.get_directory_contents(directory, response_str)
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}, "")
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)
local list, opts = scanning.scan_directory(directory, { source = "script-message" } )
if opts then opts.API_VERSION = g.API_VERSION end
local err
list, err = fb_utils.format_json_safe(list)
if not list then msg.error(err) end
opts, err = fb_utils.format_json_safe(opts)
if not opts then msg.error(err) end
mp.commandv("script-message", response_str, list or "", opts or "")
end)
end
--a helper script message for custom keybinds
--substitutes any '=>' arguments for 'script-message'
--makes chaining script-messages much easier
function script_messages.chain(...)
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
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
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
function script_messages.evaluate_expressions(...)
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
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
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

View File

@@ -0,0 +1,52 @@
local mp = require 'mp'
local o = require 'modules.options'
local g = require 'modules.globals'
local fb_utils = require 'modules.utils'
--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})
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
end
return {
extensions_list = setup_extensions_list,
root = setup_root,
}

View File

@@ -0,0 +1,490 @@
--------------------------------------------------------------------------------------------------------
-----------------------------------------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
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-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
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
function fb_utils.list.some(t, fn)
for i, v in ipairs(t) 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.
function fb_utils.list.map(t, fn)
local new_t = {}
for i, v in ipairs(t) do
new_t[i] = fn(v, i, t)
end
return new_t
end
--prints an error message and a stack trace
--accepts an error object and optionally a coroutine
--can be passed directly to xpcall
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
function fb_utils.redirect_table(t)
return setmetatable({}, { __index = t })
end
function fb_utils.set_prototype(t, proto)
return setmetatable(t, { __index = proto })
end
--prints an error if a coroutine returns an error
--unlike the next function this one still returns the results of coroutine.resume()
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
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
--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
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.
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)
return function(...)
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
end
--puts the current coroutine to sleep for the given number of seconds
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 ques 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.
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
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
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
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
function fb_utils.get_extension(filename, def)
return string.lower(filename):match("%.([^%./]+)$") or def
end
--returns the protocol scheme of the given url, or nil if there is none
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
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 = str:gsub("\\N", replace_newline)
end
return str
end
--escape lua pattern characters
function fb_utils.pattern_escape(str)
return string.gsub(str, "([%^%$%(%)%%%.%[%]%*%+%-])", "%%%1")
end
--standardises filepaths across systems
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 utils.join_path to handle protocols
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
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
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
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)
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
function fb_utils.valid_dir(dir)
if o.filter_dot_dirs and string.sub(dir, 1, 1) == "." then return false end
return true
end
function fb_utils.valid_file(file)
if o.filter_dot_files and (string.sub(file, 1, 1) == ".") then return false 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
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.
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
--this is for addons which can't filter things during their normal processing
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
function fb_utils.iterate_opt(str)
return string.gmatch(str, "([^"..fb_utils.pattern_escape(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
function fb_utils.sort_keys(t, include_item)
local keys = {}
for k in pairs(t) do
local item = g.state.list[k]
if not include_item or include_item(item) then
item.index = k
keys[#keys+1] = item
end
end
table.sort(keys, function(a,b) return a.index < b.index end)
return keys
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.
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
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) 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
t[key] = json_safe_recursive(t[key])
elseif key then
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
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
--the name argument is used for error reporting
--provides the mpv modules and the fb module to the string
function fb_utils.evaluate_string(str, chunkname, custom_env, env_defaults)
local env
if env_defaults ~= false then
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(fb_utils)
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
local chunk, err
if setfenv then
chunk, err = loadstring(str, chunkname)
if chunk then setfenv(chunk, env) end
else
chunk, err = load(str, chunkname, 't', env)
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
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) do
key = copy_table_recursive(key, references, depth - 1)
copy[key] = copy_table_recursive(value, references, depth - 1)
end
return copy
end
--a wrapper around copy_table to provide the reference table
function fb_utils.copy_table(t, depth)
--this is to handle cyclic table references
return copy_table_recursive(t, {}, depth or math.huge)
end
--functions to replace custom-keybind codes
fb_utils.code_fns = {
["%"] = "%",
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)
return i ~= -1 and ('%0'..math.ceil(math.log10(#s.list))..'d'):format(i) or 0
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
function fb_utils.get_code_pattern(codes)
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 {}) do 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
-- overrides is a map of characters->strings|functions that determines the replacement string is
-- item and state are values passed to functions in the map
-- modifier_fn is given the replacement substrings before they are placed in the main string (the return value is the new replacement 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)
local result
if type(replacers[code]) == "string" then
result = replacers[code]
--encapsulates the string if using an uppercase code
elseif not replacers[code] then
local lower_fn = replacers[code:lower()]
if not lower_fn then return end
result = string.format("%q", lower_fn(item, state))
else
result = replacers[code](item, state)
end
if modifier_fn then return modifier_fn(result) end
return result
end))
end
return fb_utils