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
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)
|