1
0
Fork 0
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

2594 lines
71 KiB

--[[
mpv-file-browser
This script allows users to browse and open files and folders entirely from within mpv.
The script uses nothing outside the mpv API, so should work identically on all platforms.
The browser can move up and down directories, start playing files and folders, or add them to the queue.
For full documentation see: https://github.com/CogentRedTester/mpv-file-browser
]]
--
local mp = require("mp")
local msg = require("mp.msg")
local utils = require("mp.utils")
local opt = require("mp.options")
local o = {
--root directories
root = "~/Videos/",
--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,
--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,
--allows custom icons be set to fix incompatabilities with some fonts
--the `\h` character is a hard space to add padding between the symbol and the text
folder_icon = "🖿",
cursor_icon = "",
indent_icon = [[\h\h\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",
--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
font_bold_header = true,
font_size_header = 35,
font_size_body = 25,
font_size_wrappers = 16,
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")
utils.shared_script_property_set("file_browser-open", "no")
--------------------------------------------------------------------------------------------------------
-----------------------------------------Environment Setup----------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
--sets the version for the file-browser API
API_VERSION = "1.3.0"
--switch the main script to a different environment so that the
--executed lua code cannot access our global variales
if setfenv then
setfenv(1, setmetatable({}, { __index = _G }))
else
_ENV = setmetatable({}, { __index = _G })
end
--creates a table for the API functions
--adds one metatable redirect to prevent addon authors from accidentally breaking file-browser
local API = { API_VERSION = API_VERSION }
package.loaded["file-browser"] = setmetatable({}, { __index = API })
local parser_API = setmetatable({}, { __index = package.loaded["file-browser"] })
local parse_state_API = {}
--------------------------------------------------------------------------------------------------------
------------------------------------------Variable Setup------------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
--the osd_overlay API was not added until v0.31. The expand-path command was not added until 0.30
local ass = mp.create_osd_overlay("ass-events")
if not ass then
return msg.error("Script requires minimum mpv version 0.31")
end
package.path = mp.command_native({ "expand-path", o.module_directory }) .. "/?.lua;" .. package.path
local 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.font_size_header,
o.font_name_header,
o.font_colour_header
),
body = ([[{\r\q2\fs%d\fn%s\c&H%s&}]]):format(o.font_size_body, o.font_name_body, o.font_colour_body),
footer_header = ([[{\r\q2\fs%d\fn%s\c&H%s&}]]):format(
o.font_size_wrappers,
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
cursor = ([[{\fn%s\c&H%s&}]]):format(o.font_name_cursor, o.font_colour_cursor),
folder = ([[{\fn%s}]]):format(o.font_name_folder),
}
local 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 }
local 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
local parse_states = setmetatable({}, { __mode = "k" })
local extensions = {}
local sub_extensions = {}
local audio_extensions = {}
local parseable_extensions = {}
local dvd_device = nil
local current_file = {
directory = nil,
name = nil,
path = nil,
}
local root = nil
--default list of compatible file extensions
--adding an item to this list is a valid request on github
local 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",
}
--------------------------------------------------------------------------------------------------------
--------------------------------------Cache Implementation----------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
--metatable of methods to manage the cache
local __cache = {}
__cache.cached_values = {
"directory",
"directory_label",
"list",
"selected",
"selection",
"parser",
"empty_text",
"co",
}
--inserts latest state values onto the cache stack
function __cache:push()
local t = {}
for _, value in ipairs(self.cached_values) do
t[value] = state[value]
end
table.insert(self, t)
end
function __cache:pop()
table.remove(self)
end
function __cache:apply()
local t = self[#self]
for _, value in ipairs(self.cached_values) do
state[value] = t[value]
end
end
function __cache:clear()
for i = 1, #self do
self[i] = nil
end
end
local cache = setmetatable({}, { __index = __cache })
--------------------------------------------------------------------------------------------------------
-----------------------------------------Utility Functions----------------------------------------------
---------------------------------------Part of the addon API--------------------------------------------
--------------------------------------------------------------------------------------------------------
API.coroutine = {}
local ABORT_ERROR = {
msg = "browser is no longer waiting for list - aborting parse",
}
--implements table.pack if on lua 5.1
if not table.pack then
table.unpack = unpack
function table.pack(...)
local t = { ... }
t.n = select("#", ...)
return t
end
end
--prints an error message and a stack trace
--accepts an error object and optionally a coroutine
--can be passed directly to xpcall
function API.traceback(errmsg, co)
if co then
msg.warn(debug.traceback(co))
else
msg.warn(debug.traceback("", 2))
end
msg.error(errmsg)
end
--prints an error if a coroutine returns an error
--unlike the next function this one still returns the results of coroutine.resume()
function API.coroutine.resume_catch(...)
local returns = table.pack(coroutine.resume(...))
if not returns[1] and returns[2] ~= ABORT_ERROR then
API.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 API.coroutine.resume_err(...)
local success, err = coroutine.resume(...)
if not success and err ~= ABORT_ERROR then
API.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 API.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 fuction to resume the current coroutine
function API.coroutine.callback()
local co = API.coroutine.assert("cannot create a coroutine callback for the main thread")
return function(...)
return API.coroutine.resume_err(co, ...)
end
end
--puts the current coroutine to sleep for the given number of seconds
function API.coroutine.sleep(n)
mp.add_timeout(n, API.coroutine.callback())
coroutine.yield()
end
--runs the given function in a coroutine, passing through any additional arguments
--this is for triggering an event in a coroutine
function API.coroutine.run(fn, ...)
local co = coroutine.create(fn)
API.coroutine.resume_err(co, ...)
end
--get the full path for the current file
function API.get_full_path(item, dir)
if item.path then
return item.path
end
return (dir or 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 API.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 API.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 API.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 API.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 API.pattern_escape(str)
return string.gsub(str, "([%^%$%(%)%%%.%[%]%*%+%-])", "%%%1")
end
--standardises filepaths across systems
function API.fix_path(str, is_directory)
str = string.gsub(str, [[\]], [[/]])
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 API.join_path(working, relative)
return API.get_protocol(relative) and relative or utils.join_path(working, relative)
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 API.sort(t)
local function padnum(d)
local r = string.match(d, "0*(.+)")
return ("%03d%s"):format(#r, r)
end
--appends the letter d or f to the start of the comparison to sort directories and folders as well
table.sort(t, function(a, b)
return a.type:sub(1, 1) .. (a.label or a.name):lower():gsub("%d+", padnum)
< b.type:sub(1, 1) .. (b.label or b.name):lower():gsub("%d+", padnum)
end)
return t
end
function API.valid_dir(dir)
if o.filter_dot_dirs and string.sub(dir, 1, 1) == "." then
return false
end
return true
end
function API.valid_file(file)
if o.filter_dot_files and (string.sub(file, 1, 1) == ".") then
return false
end
if o.filter_files and not extensions[API.get_extension(file, "")] then
return false
end
return true
end
--returns whether or not the item can be parsed
function API.parseable_item(item)
return item.type == "dir" or parseable_extensions[API.get_extension(item.name, "")]
end
--removes items and folders from the list
--this is for addons which can't filter things during their normal processing
function API.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 API.valid_dir(temp.label or temp.name))
or (temp.type == "file" and API.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 API.iterate_opt(str)
return string.gmatch(str, "([^" .. API.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 API.sort_keys(t, include_item)
local keys = {}
for k in pairs(t) do
local item = 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
local invalid_types = {
userdata = true,
thread = true,
["function"] = true,
}
local invalid_key_types = {
boolean = true,
table = true,
["nil"] = true,
}
setmetatable(invalid_key_types, { __index = invalid_types })
--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 invalid_ktypes = setmetatable({}, { __index = invalid_key_types })
local arr_length = #t
if arr_length > 0 then
invalid_ktypes.string = true
setmetatable(t, { type = "ARRAY" })
else
invalid_ktypes.number = true
setmetatable(t, { type = "MAP" })
end
for key, value in pairs(t) do
local ktype = type(key)
local vtype = type(value)
if invalid_ktypes[ktype] or invalid_types[vtype] then
t[key] = nil
elseif ktype == "number" and key > arr_length then
t[key] = nil
else
t[key] = json_safe_recursive(t[key])
end
end
return t
end
--formats a table into a json string but ensures there are no invalid datatypes inside the table first
function API.format_json_safe(t)
--operate on a copy of the table to prevent any data loss in the original table
t = json_safe_recursive(API.copy_table(t))
local success, result, err = pcall(utils.format_json, t)
if success then
return result, err
else
return nil, result
end
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)
if type(t) ~= "table" then
return t
end
if references[t] then
return references[t]
end
local mt = {
__original = t,
__index = getmetatable(t),
}
local copy = setmetatable({}, mt)
references[t] = copy
for key, value in pairs(t) do
key = copy_table_recursive(key, references)
copy[key] = copy_table_recursive(value, references)
end
return copy
end
--a wrapper around copy_table to provide the reference table
function API.copy_table(t)
--this is to handle cyclic table references
return copy_table_recursive(t, {})
end
--------------------------------------------------------------------------------------------------------
------------------------------------Parser Object Implementation----------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
--parser object for the root
--this object is not added to the parsers table so that scripts cannot get access to
--the root table, which is returned directly by parse()
local root_parser = {
name = "root",
priority = math.huge,
--if this is being called then all other parsers have failed and we've fallen back to root
can_parse = function()
return true
end,
--we return the root directory exactly as setup
parse = function(self)
return root, {
sorted = true,
filtered = true,
escaped = true,
parser = self,
directory = "",
}
end,
}
--parser ofject for native filesystems
local file_parser = {
name = "file",
priority = 110,
--as the default parser we'll always attempt to use it if all others fail
can_parse = function(_, directory)
return true
end,
--scans the given directory using the mp.utils.readdir function
parse = function(self, 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]
--filters hidden dot directories for linux
if self.valid_dir(item) then
msg.trace(item .. "/")
table.insert(new_list, { name = item .. "/", type = "dir" })
end
end
--appends files to the list of directory items
local list2 = utils.readdir(directory, "files")
for i = 1, #list2 do
local item = list2[i]
--only adds whitelisted files to the browser
if self.valid_file(item) then
msg.trace(item)
table.insert(new_list, { name = item, type = "file" })
end
end
return API.sort(new_list), { filtered = true, sorted = true }
end,
}
--------------------------------------------------------------------------------------------------------
-----------------------------------------List Formatting------------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
--appends the entered text to the overlay
local function append(text)
if text == nil then
return
end
ass.data = ass.data .. text
end
--appends a newline character to the osd
local function newline()
ass.data = ass.data .. "\\N"
end
--detects whether or not to highlight the given entry as being played
local function highlight_entry(v)
if current_file.name == nil then
return false
end
if API.parseable_item(v) then
return current_file.directory:find(API.get_full_path(v), 1, true)
else
return current_file.path == API.get_full_path(v)
end
end
--saves the directory and name of the currently playing file
local function update_current_directory(_, filepath)
--if we're in idle mode then we want to open the working directory
if filepath == nil then
current_file.directory = API.fix_path(mp.get_property("working-directory", ""), true)
current_file.name = nil
current_file.path = nil
return
elseif filepath:find("dvd://") == 1 then
filepath = dvd_device .. filepath:match("dvd://(.*)")
end
local workingDirectory = mp.get_property("working-directory", "")
local exact_path = API.join_path(workingDirectory, filepath)
exact_path = API.fix_path(exact_path, false)
current_file.directory, current_file.name = utils.split_path(exact_path)
current_file.path = exact_path
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
ass.data = style.global
local dir_name = state.directory_label or state.directory
if dir_name == "" then
dir_name = "ROOT"
end
append(style.header)
append(API.ass_escape(dir_name, style.cursor .. "\\\239\187\191n" .. style.header))
append("\\N ----------------------------------------------------")
newline()
if #state.list < 1 then
append(state.empty_text)
ass:update()
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
--adding a header to show there are items above in the list
if start > 1 then
append(style.footer_header .. (start - 1) .. " item(s) above\\N\\N")
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 then
append(style.cursor)
append((state.multiselect_start and style.multiselect or "") .. o.cursor_icon)
append("\\h" .. style.body)
else
append(o.indent_icon .. "\\h" .. style.body)
end
--sets the selection colour scheme
local multiselected = state.selection[i]
if multiselected then
append(style.multiselect)
elseif i == state.selected then
append(style.selected)
end
--prints the currently-playing icon and style
if playing_file and multiselected then
append(style.playing_selected)
elseif playing_file then
append(style.playing)
end
--sets the folder icon
if v.type == "dir" then
append(style.folder .. o.folder_icon .. "\\h" .. "{\\fn" .. o.font_name_body .. "}")
end
--adds the actual name of the item
append(v.ass or API.ass_escape(v.label or v.name, true))
newline()
end
if overflow then
append("\\N" .. style.footer_header .. #state.list - finish .. " item(s) remaining")
end
ass:update()
end
--------------------------------------------------------------------------------------------------------
--------------------------------Scroll/Select Implementation--------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
--disables multiselect
local function disable_select_mode()
state.multiselect_start = nil
state.initial_selection = nil
end
--enables multiselect
local function enable_select_mode()
state.multiselect_start = state.selected
state.initial_selection = API.copy_table(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 = state.selection[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 > state.multiselect_start then
if new_pos > original_pos then
state.selection[i] = setting
elseif i ~= new_pos then
state.selection[i] = state.initial_selection[i]
end
elseif i < state.multiselect_start then
if new_pos < original_pos then
state.selection[i] = setting
elseif i ~= new_pos then
state.selection[i] = state.initial_selection[i]
end
end
end
end
--moves the selector up and down the list by the entered amount
local function scroll(n, wrap)
local num_items = #state.list
if num_items == 0 then
return
end
local original_pos = state.selected
if original_pos + n > num_items then
state.selected = wrap and 1 or num_items
elseif original_pos + n < 1 then
state.selected = wrap and num_items or 1
else
state.selected = original_pos + n
end
if state.multiselect_start then
drag_select(original_pos, state.selected)
end
update_ass()
end
--toggles the selection
local function toggle_selection()
if not state.list[state.selected] then
return
end
state.selection[state.selected] = not state.selection[state.selected] or nil
update_ass()
end
--select all items in the list
local function select_all()
for i, _ in ipairs(state.list) do
state.selection[i] = true
end
update_ass()
end
--toggles select mode
local function toggle_select_mode()
if state.multiselect_start == nil then
enable_select_mode()
toggle_selection()
else
disable_select_mode()
update_ass()
end
end
--------------------------------------------------------------------------------------------------------
-----------------------------------------Directory Movement---------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
--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
local function select_prev_directory()
if state.prev_directory:find(state.directory, 1, true) == 1 then
local i = 1
while state.list[i] and API.parseable_item(state.list[i]) do
if state.prev_directory:find(API.get_full_path(state.list[i]), 1, true) then
state.selected = i
return
end
i = i + 1
end
end
for i, item in ipairs(state.list) do
if highlight_entry(item) then
state.selected = i
return
end
end
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", directory)
local parser, list, opts
local parse_state = API.get_parse_state()
while list == nil and not parse_state.already_deferred and index <= #parsers do
parser = 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", directory)
parse_state.directory = directory
local co = coroutine.running()
setmetatable(parse_state, { __index = parse_state_API })
if directory == "" then
return root_parser:parse()
end
parse_states[co] = parse_state
local list, opts = choose_and_parse(directory, 1)
if list == nil then
return msg.debug("no successful parsers found")
end
opts.parser = parsers[opts.id]
if not opts.filtered then
API.filter(list)
end
if not opts.sorted then
API.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 = API.coroutine.assert(
"scan_directory must be executed from within a coroutine - aborting scan " .. utils.to_string(parse_state)
)
if not 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()
API.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
API.traceback(err, new_co)
API.coroutine.resume_err(co)
end
end)
return parse_states[co]:yield()
end
--sends update requests to the different parsers
local function update_list()
msg.verbose("opening directory: " .. state.directory)
state.selected = 1
state.selection = {}
--loads the current directry from the cache to save loading time
--there will be a way to forcibly reload the current directory at some point
--the cache is in the form of a stack, items are taken off the stack when the dir moves up
if cache[1] and cache[#cache].directory == state.directory then
msg.verbose("found directory in cache")
cache:apply()
state.prev_directory = state.directory
return
end
local directory = state.directory
local list, opts = parse_directory(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() ~= state.co then
msg.verbose(ABORT_ERROR.msg)
msg.debug("expected:", state.directory, "received:", directory)
return
end
--apply fallbacks if the scan failed
if not list and cache[1] 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", state.directory)
cache:apply()
return
elseif not list then
msg.warn("could not read directory", state.directory)
list, opts = root_parser:parse()
end
state.list = list
state.parser = opts.parser
--this only matters when displaying the list on the screen, so it doesn't need to be in the scan function
if not opts.escaped then
for i = 1, #list do
list[i].ass = list[i].ass or API.ass_escape(list[i].label or list[i].name, true)
end
end
--setting custom options from parsers
state.directory_label = opts.directory_label
state.empty_text = opts.empty_text or state.empty_text
--we assume that directory is only changed when redirecting to a different location
--therefore, the cache should be wiped
if opts.directory then
state.directory = opts.directory
cache:clear()
end
if opts.selected_index then
state.selected = opts.selected_index or state.selected
if state.selected > #state.list then
state.selected = #state.list
elseif state.selected < 1 then
state.selected = 1
end
end
select_prev_directory()
state.prev_directory = state.directory
end
--rescans the folder and updates the list
local function update(moving_adjacent)
--we can only make assumptions about the directory label when moving from adjacent directories
if not moving_adjacent then
state.directory_label = nil
cache:clear()
end
state.empty_text = "~"
state.list = {}
disable_select_mode()
update_ass()
state.empty_text = "empty directory"
--the directory is always handled within a coroutine to allow addons to
--pause execution for asynchronous operations
state.co = coroutine.create(function()
update_list()
update_ass()
end)
API.coroutine.resume_err(state.co)
end
--the base function for moving to a directory
local function goto_directory(directory)
state.directory = directory
update()
end
--loads the root list
local function goto_root()
msg.verbose("jumping to root")
goto_directory("")
end
--switches to the directory of the currently playing file
local function goto_current_dir()
msg.verbose("jumping to current directory")
goto_directory(current_file.directory)
end
--moves up a directory
local function up_dir()
local dir = state.directory:reverse()
local index = dir:find("[/\\]")
while index == 1 do
dir = dir:sub(2)
index = dir:find("[/\\]")
end
if index == nil then
state.directory = ""
else
state.directory = dir:sub(index):reverse()
end
--we can make some assumptions about the next directory label when moving up or down
if state.directory_label then
state.directory_label = state.directory_label:match("^(.+/)[^/]+/$")
end
update(true)
cache:pop()
end
--moves down a directory
local function down_dir()
local current = state.list[state.selected]
if not current or not API.parseable_item(current) then
return
end
cache:push()
local directory, redirected = API.get_new_directory(current, state.directory)
state.directory = directory
--we can make some assumptions about the next directory label when moving up or down
if state.directory_label then
state.directory_label = state.directory_label .. (current.label or current.name)
end
update(not redirected)
end
------------------------------------------------------------------------------------------
------------------------------------Browser Controls--------------------------------------
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
--opens the browser
local function open()
for _, v in ipairs(state.keybinds) do
mp.add_forced_key_binding(v[1], "dynamic/" .. v[2], v[3], v[4])
end
utils.shared_script_property_set("file_browser-open", "yes")
state.hidden = false
if state.directory == nil then
local path = mp.get_property("path")
update_current_directory(nil, path)
if path or o.default_to_working_directory then
goto_current_dir()
else
goto_root()
end
return
end
if state.flag_update then
update_current_directory(nil, mp.get_property("path"))
end
if not state.flag_update then
ass:update()
else
state.flag_update = false
update_ass()
end
end
--closes the list and sets the hidden flag
local function close()
for _, v in ipairs(state.keybinds) do
mp.remove_key_binding("dynamic/" .. v[2])
end
utils.shared_script_property_set("file_browser-open", "no")
state.hidden = true
ass:remove()
end
--toggles the list
local function toggle()
if state.hidden then
open()
else
close()
end
end
--run when the escape key is used
local function escape()
--if multiple items are selection cancel the
--selection instead of closing the browser
if next(state.selection) or state.multiselect_start then
state.selection = {}
disable_select_mode()
update_ass()
return
end
close()
end
--opens a specific directory
local function browse_directory(directory)
if not directory then
return
end
directory = mp.command_native({ "expand-path", directory }, "")
-- directory = join_path( mp.get_property("working-directory", ""), directory )
if directory ~= "" then
directory = API.fix_path(directory, true)
end
msg.verbose("recieved directory from script message: " .. directory)
if directory == "dvd://" then
directory = dvd_device
end
goto_directory(directory)
open()
end
------------------------------------------------------------------------------------------
---------------------------------File/Playlist Opening------------------------------------
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
--adds a file to the playlist and changes the flag to `append-play` in preparation
--for future items
local function loadfile(file, opts)
if o.substitute_backslash and not API.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 not mp.commandv("loadfile", file, opts.flag) 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 = parse_directory(directory, { source = "loadlist" })
if list == 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 API.parseable_item(item) then
API.coroutine.run(
concurrent_loadlist_wrapper,
API.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
API.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
API.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 sub_extensions[API.get_extension(item.name, "")]
and not audio_extensions[API.get_extension(item.name, "")]
then
while not item._sublist and API.parseable_item(item) do
coroutine.yield()
end
if API.parseable_item(item) then
concurrent_loadlist_append(item._sublist, load_opts)
else
loadfile(API.get_full_path(item, directory), load_opts)
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 = parse_directory(directory, { source = "loadlist" })
if list == 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 sub_extensions[API.get_extension(item.name, "")]
and not audio_extensions[API.get_extension(item.name, "")]
then
if API.parseable_item(item) then
custom_loadlist_recursive(API.get_new_directory(item, directory), load_opts, prev_dirs)
else
local path = API.get_full_path(item, directory)
loadfile(path, load_opts)
end
end
end
end
--a wrapper for the custom_loadlist_recursive function
local function loadlist(item, opts)
local dir = API.get_full_path(item, opts.directory)
local num_items = opts.items_appended
if o.concurrent_recursion then
item = API.copy_table(item)
opts.co = API.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()
API.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 == 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 sub_extensions[API.get_extension(item.name, "")]
and not audio_extensions[API.get_extension(item.name, "")]
then
local p = API.get_full_path(item)
if p == path then
pos = file_count
else
loadfile(p, opts)
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 API.parseable_item(item) then
return loadlist(item, opts)
end
local path = API.get_full_path(item, opts.directory)
if sub_extensions[API.get_extension(item.name, "")] then
mp.commandv("sub-add", path, opts.flag == "replace" and "select" or "auto")
elseif audio_extensions[API.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)
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
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 = API.sort_keys(state.selection)
--reset the selection after
state.selection = {}
disable_select_mode()
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
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)
API.coroutine.run(open_file_coroutine, {
flag = flag,
autoload = (autoload ~= o.autoload and flag == "replace"),
directory = state.directory,
items_appended = 0,
})
end
------------------------------------------------------------------------------------------
----------------------------------Keybind Implementation----------------------------------
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
state.keybinds = {
{
"ENTER",
"play",
function()
open_file("replace", false)
end,
},
{
"Shift+ENTER",
"play_append",
function()
open_file("append-play", false)
end,
},
{
"Alt+ENTER",
"play_autoload",
function()
open_file("replace", true)
end,
},
{ "ESC", "close", escape },
{ "RIGHT", "down_dir", down_dir },
{ "LEFT", "up_dir", up_dir },
{
"DOWN",
"scroll_down",
function()
scroll(1, o.wrap)
end,
{ repeatable = true },
},
{
"UP",
"scroll_up",
function()
scroll(-1, o.wrap)
end,
{ repeatable = true },
},
{
"PGDWN",
"page_down",
function()
scroll(o.num_entries)
end,
{ repeatable = true },
},
{
"PGUP",
"page_up",
function()
scroll(-o.num_entries)
end,
{ repeatable = true },
},
{
"Shift+PGDWN",
"list_bottom",
function()
scroll(math.huge)
end,
},
{
"Shift+PGUP",
"list_top",
function()
scroll(-math.huge)
end,
},
{ "HOME", "goto_current", goto_current_dir },
{ "Shift+HOME", "goto_root", goto_root },
{
"Ctrl+r",
"reload",
function()
cache:clear()
update()
end,
},
{ "s", "select_mode", toggle_select_mode },
{ "S", "select_item", toggle_selection },
{ "Ctrl+a", "select_all", select_all },
}
--characters used for custom keybind codes
local CUSTOM_KEYBIND_CODES = "%fFnNpPdDrR"
--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(cmd, items, funct)
if not items[1] then
return
end
local str = funct(items[1])
for i = 2, #items do
str = str .. (cmd["concat-string"] or " ") .. funct(items[i])
end
return str
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] = cmd.command[i][j]:gsub("%%[" .. CUSTOM_KEYBIND_CODES .. "]", {
["%%"] = "%",
["%f"] = create_item_string(cmd, items, function(item)
return item and API.get_full_path(item, state.directory) or ""
end),
["%F"] = create_item_string(cmd, items, function(item)
return string.format("%q", item and API.get_full_path(item, state.directory) or "")
end),
["%n"] = create_item_string(cmd, items, function(item)
return item and (item.label or item.name) or ""
end),
["%N"] = create_item_string(cmd, items, function(item)
return string.format("%q", item and (item.label or item.name) or "")
end),
["%p"] = state.directory or "",
["%P"] = string.format("%q", state.directory or ""),
["%d"] = (state.directory_label or state.directory):match("([^/]+)/?$") or "",
["%D"] = string.format("%q", (state.directory_label or state.directory):match("([^/]+)/$") or ""),
["%r"] = state.parser.keybind_name or state.parser.name or "",
["%R"] = string.format("%q", state.parser.keybind_name or state.parser.name or ""),
})
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 _, cmd in ipairs(custom_cmds) do
msg.debug("running command:", utils.to_string(cmd))
mp.command_native(cmd)
end
end
--runs one of the custom commands
local function custom_command(cmd, state, co)
if cmd.parser and cmd.parser ~= (state.parser.keybind_name or state.parser.name) then
return false
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 the directory is empty, and this command needs to work on an item, then abort and fallback to the next command
if cmd.codes and not state.list[state.selected] then
if cmd.codes["%f"] or cmd.codes["%F"] or cmd.codes["%n"] or cmd.codes["%N"] then
return false
end
end
run_custom_command(cmd, { state.list[state.selected] }, state)
return true
end
--runs the command on all multi-selected items
local selection = API.sort_keys(state.selection, function(item)
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" then
for i, _ in ipairs(selection) do
run_custom_command(cmd, { selection[i] }, state)
if cmd.delay then
mp.add_timeout(cmd.delay, function()
API.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))
--these are for the default keybinds, or from addons which use direct functions
local addon_fn = type(keybind.command) == "function"
local fn = addon_fn and keybind.command or custom_command
if keybind.passthrough ~= nil then
fn(keybind, addon_fn and API.copy_table(state) or state, co)
if keybind.passthrough == true and keybind.prev_key then
run_keybind_recursive(keybind.prev_key, state, co)
end
else
if fn(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 = state.directory,
directory_label = state.directory_label,
list = state.list, --the list should remain unchanged once it has been saved to the global state, new directories get new tables
selected = state.selected,
selection = API.copy_table(state.selection),
parser = 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))
API.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
value:gsub("%%[" .. CUSTOM_KEYBIND_CODES .. "]", function(code)
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)
--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]
table.insert(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(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 = #parsers, 1, -1 do
local parser = 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 = API.copy_table(keybind)
end
keybind.name = parsers[parser].id .. "/" .. (keybind.name or tostring(i))
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
--------------------------------------------------------------------------------------------------------
-------------------------------------------API Functions------------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
--these functions we'll provide as-is
API.redraw = update_ass
API.rescan = update
API.browse_directory = browse_directory
function API.clear_cache()
cache:clear()
end
--a wrapper around scan_directory for addon API
function API.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 parse_directory(directory, parse_state)
end
--register file extensions which can be opened by the browser
function API.register_parseable_extension(ext)
parseable_extensions[string.lower(ext)] = true
end
function API.remove_parseable_extension(ext)
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 API.add_default_extension(ext)
table.insert(compatible_file_extensions, ext)
end
--add item to root at position pos
function API.insert_root_item(item, pos)
msg.verbose("adding item to root", item.label or item.name)
item.ass = item.ass or API.ass_escape(item.label or item.name)
item.type = "dir"
table.insert(root, pos or (#root + 1), item)
end
--providing getter and setter functions so that addons can't modify things directly
function API.get_script_opts()
return API.copy_table(o)
end
function API.get_opt(key)
return o[key]
end
function API.get_extensions()
return API.copy_table(extensions)
end
function API.get_sub_extensions()
return API.copy_table(sub_extensions)
end
function API.get_audio_extensions()
return API.copy_table(audio_extensions)
end
function API.get_parseable_extensions()
return API.copy_table(parseable_extensions)
end
function API.get_state()
return API.copy_table(state)
end
function API.get_dvd_device()
return dvd_device
end
function API.get_parsers()
return API.copy_table(parsers)
end
function API.get_root()
return API.copy_table(root)
end
function API.get_directory()
return state.directory
end
function API.get_list()
return API.copy_table(state.list)
end
function API.get_current_file()
return API.copy_table(current_file)
end
function API.get_current_parser()
return state.parser:get_id()
end
function API.get_current_parser_keyname()
return state.parser.keybind_name or state.parser.name
end
function API.get_selected_index()
return state.selected
end
function API.get_selected_item()
return API.copy_table(state.list[state.selected])
end
function API.get_open_status()
return not state.hidden
end
function API.get_parse_state(co)
return parse_states[co or coroutine.running() or ""]
end
function API.set_empty_text(str)
state.empty_text = str
API.redraw()
end
function API.set_selected_index(index)
if type(index) ~= "number" then
return false
end
if index < 1 then
index = 1
end
if index > #state.list then
index = #state.list
end
state.selected = index
API.redraw()
return index
end
function parser_API:get_index()
return parsers[self].index
end
function parser_API:get_id()
return parsers[self].id
end
--runs choose_and_parse starting from the next parser
function parser_API:defer(directory)
msg.trace("deferring to other parsers...")
local list, opts = choose_and_parse(directory, self:get_index() + 1)
API.get_parse_state().already_deferred = true
return list, opts
end
--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 == 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 ~= state.co then
msg.verbose("browser no longer waiting for list - aborting parse for", self.directory)
error(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() == state.co
end
--------------------------------------------------------------------------------------------------------
-----------------------------------------Setup Functions------------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
local API_MAJOR, API_MINOR, API_PATCH = API_VERSION:match("(%d+)%.(%d+)%.(%d+)")
--checks if the given parser has a valid version number
local function check_api_version(parser)
local version = parser.version or "1.0.0"
local major, minor = version:match("(%d+)%.(%d+)")
if not major or not minor then
return msg.error("Invalid version number")
elseif major ~= API_MAJOR then
return msg.error(
"parser",
parser.name,
"has wrong major version number, expected",
("v%d.x.x"):format(API_MAJOR),
"got",
"v" .. version
)
elseif minor > API_MINOR then
msg.warn(
"parser",
parser.name,
"has newer minor version number than API, expected",
("v%d.%d.x"):format(API_MAJOR, API_MINOR),
"got",
"v" .. version
)
end
return true
end
--create a unique id for the given parser
local function set_parser_id(parser)
local name = parser.name
if parsers[name] then
local n = 2
name = parser.name .. "_" .. n
while parsers[name] do
n = n + 1
name = parser.name .. "_" .. n
end
end
parsers[name] = parser
parsers[parser] = { id = name }
end
local function redirect_table(t)
return setmetatable({}, { __index = t })
end
--loads an addon in a separate environment
local function load_addon(path)
local name_sqbr = string.format("[%s]", path:match("/([^/]*)%.lua$"))
local addon_environment = redirect_table(_G)
addon_environment._G = addon_environment
--gives each addon custom debug messages
addon_environment.package = redirect_table(addon_environment.package)
addon_environment.package.loaded = 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, API.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) 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(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 = load_addon(path)
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 setup_addons()
local addon_dir = mp.command_native({ "expand-path", o.addon_directory .. "/" })
local files = utils.readdir(addon_dir)
if not files then
error("could not read addon directory")
end
for _, file in ipairs(files) do
setup_addon(file, addon_dir .. file)
end
table.sort(parsers, function(a, b)
return a.priority < b.priority
end)
--we want to store the indexes of the parsers
for i = #parsers, 1, -1 do
parsers[parsers[i]].index = i
end
--we want to run the setup functions for each addon
for index, parser in ipairs(parsers) do
if parser.setup then
local success = xpcall(function()
parser:setup()
end, API.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(parsers, index)
end
end
end
end
--sets up the compatible extensions list
local function setup_extensions_list()
--setting up subtitle extensions
for ext in API.iterate_opt(o.subtitle_extensions:lower()) do
sub_extensions[ext] = true
extensions[ext] = true
end
--setting up audio extensions
for ext in API.iterate_opt(o.audio_extensions:lower()) do
audio_extensions[ext] = true
extensions[ext] = true
end
--adding file extensions to the set
for _, ext in ipairs(compatible_file_extensions) do
extensions[ext] = true
end
--adding extra extensions on the whitelist
for str in API.iterate_opt(o.extension_whitelist:lower()) do
extensions[str] = true
end
--removing extensions that are in the blacklist
for str in API.iterate_opt(o.extension_blacklist:lower()) do
extensions[str] = nil
end
end
--splits the string into a table on the semicolons
local function setup_root()
root = {}
for str in API.iterate_opt(o.root) do
local path = mp.command_native({ "expand-path", str })
path = API.fix_path(path, true)
local temp = { name = path, type = "dir", label = str, ass = API.ass_escape(str, true) }
root[#root + 1] = temp
end
end
setup_root()
setup_parser(file_parser, "file-browser.lua")
if o.addons then
--all of the API functions need to be defined before this point for the addons to be able to access them safely
setup_addons()
end
--these need to be below the addon setup in case any parsers add custom entries
setup_extensions_list()
setup_keybinds()
------------------------------------------------------------------------------------------
------------------------------Other Script Compatability----------------------------------
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
local function scan_directory_json(directory, response_str)
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 = API.fix_path(directory, true)
end
msg.verbose(
("recieved %q from 'get-directory-contents' script message - returning result to %q"):format(
directory,
response_str
)
)
local list, opts = parse_directory(directory, { source = "script-message" })
opts.API_VERSION = API_VERSION
local err
list, err = API.format_json_safe(list)
if not list then
msg.error(err)
end
opts, err = API.format_json_safe(opts)
if not opts then
msg.error(err)
end
mp.commandv("script-message", response_str, list or "", opts or "")
end
pcall(function()
local input = require("user-input-module")
mp.add_key_binding("Alt+o", "browse-directory/get-user-input", function()
input.get_user_input(browse_directory, { request_text = "open directory:" })
end)
end)
------------------------------------------------------------------------------------------
--------------------------------mpv API Callbacks-----------------------------------------
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
--we don't want to add any overhead when the browser isn't open
mp.observe_property("path", "string", function(_, path)
if not state.hidden then
update_current_directory(_, path)
update_ass()
else
state.flag_update = true
end
end)
--updates the dvd_device
mp.observe_property("dvd-device", "string", function(_, device)
if not device or device == "" then
device = "/dev/dvd/"
end
dvd_device = API.fix_path(device, true)
end)
--declares the keybind to open the browser
mp.add_key_binding("MENU", "browse-files", toggle)
mp.add_key_binding("Ctrl+o", "open-browser", open)
--allows keybinds/other scripts to auto-open specific directories
mp.register_script_message("browse-directory", browse_directory)
--allows other scripts to request directory contents from file-browser
mp.register_script_message("get-directory-contents", function(directory, response_str)
API.coroutine.run(scan_directory_json, directory, response_str)
end)