2083 lines
76 KiB
Lua
2083 lines
76 KiB
Lua
|
--[[
|
||
|
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 = "~/",
|
||
|
|
||
|
--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_opacity_selection_marker = "99",
|
||
|
|
||
|
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),
|
||
|
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),
|
||
|
}
|
||
|
|
||
|
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
|
||
|
|
||
|
--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 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 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(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---------------------------------------------
|
||
|
--------------------------------------------------------------------------------------------------------
|
||
|
--------------------------------------------------------------------------------------------------------
|
||
|
|
||
|
--selects the first item in the list which is highlighted as playing
|
||
|
local function select_playing_item()
|
||
|
for i,item in ipairs(state.list) do
|
||
|
if highlight_entry(item) then
|
||
|
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
|
||
|
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
|
||
|
|
||
|
select_playing_item()
|
||
|
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()
|
||
|
parse_states[co] = setmetatable(parse_state, { __index = parse_state_API })
|
||
|
|
||
|
if directory == "" then return root_parser:parse() end
|
||
|
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(moving_adjacent)
|
||
|
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
|
||
|
|
||
|
if moving_adjacent then select_prev_directory()
|
||
|
else select_playing_item() end
|
||
|
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
|
||
|
API.coroutine.run(function()
|
||
|
state.co = coroutine.running()
|
||
|
update_list(moving_adjacent)
|
||
|
update_ass()
|
||
|
end)
|
||
|
end
|
||
|
|
||
|
--the base function for moving to a directory
|
||
|
local function goto_directory(directory)
|
||
|
state.directory = directory
|
||
|
update(false)
|
||
|
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()
|
||
|
if not state.hidden then return end
|
||
|
|
||
|
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()
|
||
|
if state.hidden then return end
|
||
|
|
||
|
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}
|
||
|
}
|
||
|
|
||
|
--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
|
||
|
|
||
|
--characters used for custom keybind codes
|
||
|
local CUSTOM_KEYBIND_CODES = "%%["..API.pattern_escape("%fFnNpPdDrR").."]"
|
||
|
local code_fns
|
||
|
code_fns = {
|
||
|
["%f"] = function(cmd, items, s)
|
||
|
return create_item_string(cmd, items, function(item)
|
||
|
return item and API.get_full_path(item, s.directory) or ""
|
||
|
end)
|
||
|
end,
|
||
|
["%F"] = function(cmd, items, s)
|
||
|
return create_item_string(cmd, items, function(item)
|
||
|
return ("%q"):format(item and API.get_full_path(item, s.directory) or "")
|
||
|
end)
|
||
|
end,
|
||
|
["%n"] = function(cmd, items)
|
||
|
return create_item_string(cmd, items, function(item)
|
||
|
return item and (item.label or item.name) or ""
|
||
|
end)
|
||
|
end,
|
||
|
["%N"] = function(cmd, items)
|
||
|
return create_item_string(cmd, items, function(item)
|
||
|
return ("%q"):format(item and (item.label or item.name) or "")
|
||
|
end)
|
||
|
end,
|
||
|
|
||
|
["%%"] = "%",
|
||
|
["%p"] = function(_, _, s) return 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,
|
||
|
}
|
||
|
|
||
|
--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, function(code)
|
||
|
if type(code_fns[code]) == "string" then return code_fns[code] end
|
||
|
|
||
|
--encapsulates the string if using an uppercase code
|
||
|
if not code_fns[code] then
|
||
|
local lower = code_fns[code:lower()]
|
||
|
if not lower then return end
|
||
|
return string.format("%q", lower(cmd, items, state))
|
||
|
end
|
||
|
|
||
|
return code_fns[code](cmd, items, state)
|
||
|
end)
|
||
|
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)
|
||
|
|