diff options
Diffstat (limited to 'mpv/scripts/file-browser/modules/addons')
| -rw-r--r-- | mpv/scripts/file-browser/modules/addons/cache.lua | 146 | ||||
| -rw-r--r-- | mpv/scripts/file-browser/modules/addons/file.lua | 46 | ||||
| -rw-r--r-- | mpv/scripts/file-browser/modules/addons/find.lua | 124 | ||||
| -rw-r--r-- | mpv/scripts/file-browser/modules/addons/home-label.lua | 31 | ||||
| -rw-r--r-- | mpv/scripts/file-browser/modules/addons/last-opened-directory.lua | 62 | ||||
| -rw-r--r-- | mpv/scripts/file-browser/modules/addons/ls.lua | 68 | ||||
| -rw-r--r-- | mpv/scripts/file-browser/modules/addons/root.lua | 26 | ||||
| -rw-r--r-- | mpv/scripts/file-browser/modules/addons/windir.lua | 218 | ||||
| -rw-r--r-- | mpv/scripts/file-browser/modules/addons/winroot.lua | 62 |
9 files changed, 783 insertions, 0 deletions
diff --git a/mpv/scripts/file-browser/modules/addons/cache.lua b/mpv/scripts/file-browser/modules/addons/cache.lua new file mode 100644 index 0000000..b1011fd --- /dev/null +++ b/mpv/scripts/file-browser/modules/addons/cache.lua @@ -0,0 +1,146 @@ +--[[ + This file is an internal file-browser addon. + It should not be imported like a normal module. + + Maintains a cache of the accessed directories to improve + parsing speed. Disabled by default. +]] + +local mp = require 'mp' +local msg = require 'mp.msg' +local utils = require 'mp.utils' + +local fb = require 'file-browser' + +---@type ParserConfig +local cacheParser = { + name = 'cache', + priority = 0, + api_version = '1.9', +} + +---@class CacheEntry +---@field list List +---@field opts Opts? +---@field timeout MPTimer + +---@type table<string,CacheEntry> +local cache = {} + +---@type table<string,(async fun(list: List?, opts: Opts?))[]> +local pending_parses = {} + +---@param directories? string[] +local function clear_cache(directories) + if directories then + msg.debug('clearing cache for', #directories, 'directorie(s)') + for _, dir in ipairs(directories) do + if cache[dir] then + msg.trace('clearing cache for', dir) + cache[dir].timeout:kill() + cache[dir] = nil + end + end + else + msg.debug('clearing cache') + for _, entry in pairs(cache) do + entry.timeout:kill() + end + cache = {} + end +end + +---@type string +local prev_directory = '' + +function cacheParser:can_parse(directory, parse_state) + -- allows the cache to be forcibly used or bypassed with the + -- cache/use parse property. + if parse_state.properties.cache and parse_state.properties.cache.use ~= nil then + if parse_state.source == 'browser' then prev_directory = directory end + return parse_state.properties.cache.use + end + + -- the script message is guaranteed to always bypass the cache + if parse_state.source == 'script-message' then return false end + if not fb.get_opt('cache') or directory == '' then return false end + + -- clear the cache if reloading the current directory in the browser + -- this means that fb.rescan() should maintain expected behaviour + if parse_state.source == 'browser' then + if prev_directory == directory then clear_cache({directory}) end + prev_directory = directory + end + + return true +end + +---@async +function cacheParser:parse(directory) + if cache[directory] then + msg.verbose('fetching', directory, 'contents from cache') + cache[directory].timeout:kill() + cache[directory].timeout:resume() + return cache[directory].list, cache[directory].opts + end + + ---@type List?, Opts? + local list, opts + + -- if another parse is already running on the same directory, then wait and use the same result + if not pending_parses[directory] then + pending_parses[directory] = {} + list, opts = self:defer(directory) + else + msg.debug('parse for', directory, 'already running - waiting for other parse to finish...') + table.insert(pending_parses[directory], fb.coroutine.callback(30)) + list, opts = coroutine.yield() + end + + local pending = pending_parses[directory] + -- need to clear the pending parses before resuming them or they will also attempt to resume the parses + pending_parses[directory] = nil + if pending and #pending > 0 then + msg.debug('resuming', #pending, 'pending parses for', directory) + for _, cb in ipairs(pending) do + cb(list, opts) + end + end + + if not list then return end + + -- pending will be truthy for the original parse and falsy for any parses that were pending + if pending then + msg.debug('storing', directory, 'contents in cache') + cache[directory] = { + list = list, + opts = opts, + timeout = mp.add_timeout(120, function() cache[directory] = nil end), + } + end + + return list, opts +end + +cacheParser.keybinds = { + { + key = 'Ctrl+Shift+r', + name = 'clear', + command = function() clear_cache() ; fb.rescan() end, + } +} + +-- provide method of clearing the cache through script messages +mp.register_script_message('cache/clear', function(dirs) + if not dirs then + return clear_cache() + end + + ---@type string[]? + local directories = utils.parse_json(dirs) + if not directories then msg.error('unable to parse', dirs) end + + clear_cache(directories) +end) + +return cacheParser diff --git a/mpv/scripts/file-browser/modules/addons/file.lua b/mpv/scripts/file-browser/modules/addons/file.lua new file mode 100644 index 0000000..a57573c --- /dev/null +++ b/mpv/scripts/file-browser/modules/addons/file.lua @@ -0,0 +1,46 @@ +-- This file is an internal file-browser addon. +-- It should not be imported like a normal module. + +local msg = require 'mp.msg' +local utils = require 'mp.utils' + +---Parser for native filesystems +---@type ParserConfig +local file_parser = { + name = "file", + priority = 110, + api_version = '1.0.0', +} + +--try to parse any directory except for the root +function file_parser:can_parse(directory) + return directory ~= '' +end + +--scans the given directory using the mp.utils.readdir function +function file_parser:parse(directory) + local new_list = {} + local list1 = utils.readdir(directory, 'dirs') + if list1 == nil then return nil end + + --sorts folders and formats them into the list of directories + for i=1, #list1 do + local item = list1[i] + + msg.trace(item..'/') + table.insert(new_list, {name = item..'/', type = 'dir'}) + end + + --appends files to the list of directory items + local list2 = utils.readdir(directory, 'files') + if list2 == nil then return nil end + for i=1, #list2 do + local item = list2[i] + + msg.trace(item) + table.insert(new_list, {name = item, type = 'file'}) + end + return new_list +end + +return file_parser diff --git a/mpv/scripts/file-browser/modules/addons/find.lua b/mpv/scripts/file-browser/modules/addons/find.lua new file mode 100644 index 0000000..0b64d92 --- /dev/null +++ b/mpv/scripts/file-browser/modules/addons/find.lua @@ -0,0 +1,124 @@ +--[[ + This file is an internal file-browser addon. + It should not be imported like a normal module. + + Allows searching the current directory. +]]-- + +local msg = require "mp.msg" +local fb = require "file-browser" +local input_loaded, input = pcall(require, "mp.input") +local user_input_loaded, user_input = pcall(require, "user-input-module") + +---@type ParserConfig +local find = { + api_version = "1.3.0" +} + +---@type thread|nil +local latest_coroutine = nil + +---@type State +local global_fb_state = getmetatable(fb.get_state()).__original + +---@param name string +---@param query string +---@return boolean +local function compare(name, query) + if name:find(query) then return true end + if name:lower():find(query) then return true end + if name:upper():find(query) then return true end + + return false +end + +---@async +---@param key Keybind +---@param state State +---@param co thread +---@return boolean? +local function main(key, state, co) + if not state.list then return false end + + ---@type string + local text + if key.name == "find/find" then text = "Find: enter search string" + else text = "Find: enter advanced search string" end + + if input_loaded then + input.get({ + prompt = text .. "\n>", + id = "file-browser/find", + submit = fb.coroutine.callback(), + }) + elseif user_input_loaded then + user_input.get_user_input( fb.coroutine.callback(), { text = text, id = "find", replace = true } ) + end + + local query, error = coroutine.yield() + if input_loaded then input.terminate() end + if not query then return msg.debug(error) end + + -- allow the directory to be changed before this point + local list = fb.get_list() + local parse_id = global_fb_state.co + + if key.name == "find/find" then + query = fb.pattern_escape(query) + end + + local results = {} + + for index, item in ipairs(list) do + if compare(item.label or item.name, query) then + table.insert(results, index) + end + end + + if (#results < 1) then + msg.warn("No matching items for '"..query.."'") + return + end + + --keep cycling through the search results if any are found + --putting this into a separate coroutine removes any passthrough ambiguity + --the final return statement should return to `step_find` not any other function + ---@async + fb.coroutine.run(function() + latest_coroutine = coroutine.running() + ---@type number + local rindex = 1 + while (true) do + + if rindex == 0 then rindex = #results + elseif rindex == #results + 1 then rindex = 1 end + + fb.set_selected_index(results[rindex]) + local direction = coroutine.yield(true) --[[@as number]] + rindex = rindex + direction + + if parse_id ~= global_fb_state.co then + latest_coroutine = nil + return + end + end + end) +end + +local function step_find(key) + if not latest_coroutine then return false end + ---@type number + local direction = 0 + if key.name == "find/next" then direction = 1 + elseif key.name == "find/prev" then direction = -1 end + return fb.coroutine.resume_err(latest_coroutine, direction) +end + +find.keybinds = { + {"Ctrl+f", "find", main, {}}, + {"Ctrl+F", "find_advanced", main, {}}, + {"n", "next", step_find, {}}, + {"N", "prev", step_find, {}}, +} + +return find diff --git a/mpv/scripts/file-browser/modules/addons/home-label.lua b/mpv/scripts/file-browser/modules/addons/home-label.lua new file mode 100644 index 0000000..5e6cf8e --- /dev/null +++ b/mpv/scripts/file-browser/modules/addons/home-label.lua @@ -0,0 +1,31 @@ +--[[ + An addon for mpv-file-browser which displays ~/ for the home directory instead of the full path +]]-- + +local mp = require "mp" +local fb = require "file-browser" + +local home = fb.fix_path(mp.command_native({"expand-path", "~/"}) --[[@as string]], true) + +---@type ParserConfig +local home_label = { + priority = 100, + api_version = "1.0.0" +} + +function home_label:can_parse(directory) + if not fb.get_opt('home_label') then return false end + return directory:sub(1, home:len()) == home +end + +---@async +function home_label:parse(directory) + local list, opts = self:defer(directory) + if not opts then opts = {} end + if (not opts.directory or opts.directory == directory) and not opts.directory_label then + opts.directory_label = "~/"..(directory:sub(home:len()+1) or "") + end + return list, opts +end + +return home_label
\ No newline at end of file diff --git a/mpv/scripts/file-browser/modules/addons/last-opened-directory.lua b/mpv/scripts/file-browser/modules/addons/last-opened-directory.lua new file mode 100644 index 0000000..c68f408 --- /dev/null +++ b/mpv/scripts/file-browser/modules/addons/last-opened-directory.lua @@ -0,0 +1,62 @@ +--[[ + An addon for mpv-file-browser which stores the last opened directory and + sets it as the opened directory the next time mpv is opened. + + Available at: https://github.com/CogentRedTester/mpv-file-browser/tree/master/addons +]]-- + +local mp = require 'mp' +local msg = require 'mp.msg' + +local fb = require 'file-browser' + +local state_file = mp.command_native({'expand-path', fb.get_opt('last_opened_directory_file')}) --[[@as string]] +msg.verbose('using', state_file) + +---@param directory? string +---@return nil +local function write_directory(directory) + if not fb.get_opt('save_last_opened_directory') then return end + + local file = io.open(state_file, 'w+') + + if not file then return msg.error('could not open', state_file, 'for writing') end + + directory = directory or fb.get_directory() or '' + msg.verbose('writing', directory, 'to', state_file) + file:write(directory) + file:close() +end + +---@type ParserConfig +local addon = { + api_version = '1.7.0', + priority = 0, +} + +function addon:setup() + if not fb.get_opt('default_to_last_opened_directory') then return end + + local file = io.open(state_file, "r") + if not file then + return msg.info('failed to open', state_file, 'for reading (may be due to first load)') + end + + local dir = file:read("*a") + msg.verbose('setting default directory to', dir) + fb.browse_directory(dir, false) + file:close() +end + +function addon:can_parse(dir, parse_state) + if parse_state.source == 'browser' then write_directory(dir) end + return false +end + +function addon:parse() + return nil +end + +mp.register_event('shutdown', function() write_directory() end) + +return addon diff --git a/mpv/scripts/file-browser/modules/addons/ls.lua b/mpv/scripts/file-browser/modules/addons/ls.lua new file mode 100644 index 0000000..8c2e23c --- /dev/null +++ b/mpv/scripts/file-browser/modules/addons/ls.lua @@ -0,0 +1,68 @@ +--[[ + An addon for mpv-file-browser which uses the Linux ls command to parse native directories + This behaves near identically to the native parser, but IO is done asynchronously. + + Available at: https://github.com/CogentRedTester/mpv-file-browser/tree/master/addons +]]-- + +local mp = require "mp" +local msg = require "mp.msg" +local fb = require "file-browser" + +local PLATFORM = fb.get_platform() + +---@type ParserConfig +local ls = { + priority = 109, + api_version = "1.9.0", + name = "ls", + keybind_name = "file" +} + +---@async +---@param args string[] +---@param parse_state ParseState +---@return string|nil +local function command(args, parse_state) + local async = mp.command_native_async({ + name = "subprocess", + playback_only = false, + capture_stdout = true, + capture_stderr = true, + args = args + }, fb.coroutine.callback(30)) + + ---@type boolean, boolean, MPVSubprocessResult + local completed, _, cmd = parse_state:yield() + if not completed then + msg.warn('read timed out for:', table.unpack(args)) + mp.abort_async_command(async) + return nil + end + + return cmd.status == 0 and cmd.stdout or nil +end + +function ls:can_parse(directory) + if not fb.get_opt('ls_parser') then return false end + return PLATFORM ~= 'windows' and directory ~= '' and not fb.get_protocol(directory) +end + +---@async +function ls:parse(directory, parse_state) + local list = {} + local files = command({"ls", "-1", "-p", "-A", "-N", "--zero", "-L", directory}, parse_state) + + if not files then return nil end + + for str in files:gmatch("%Z+") do + local is_dir = str:sub(-1) == "/" + msg.trace(str) + + table.insert(list, {name = str, type = is_dir and "dir" or "file"}) + end + + return list +end + +return ls diff --git a/mpv/scripts/file-browser/modules/addons/root.lua b/mpv/scripts/file-browser/modules/addons/root.lua new file mode 100644 index 0000000..412a5ba --- /dev/null +++ b/mpv/scripts/file-browser/modules/addons/root.lua @@ -0,0 +1,26 @@ +-- This file is an internal file-browser addon. +-- It should not be imported like a normal module. + +local g = require 'modules.globals' + +---Parser for the root. +---@type ParserConfig +local root_parser = { + name = "root", + priority = math.huge, + api_version = '1.0.0', +} + +function root_parser:can_parse(directory) + return directory == '' +end + +--we return the root directory exactly as setup +function root_parser:parse() + return g.root, { + sorted = true, + filtered = true, + } +end + +return root_parser diff --git a/mpv/scripts/file-browser/modules/addons/windir.lua b/mpv/scripts/file-browser/modules/addons/windir.lua new file mode 100644 index 0000000..436fe33 --- /dev/null +++ b/mpv/scripts/file-browser/modules/addons/windir.lua @@ -0,0 +1,218 @@ +--[[ + An addon for mpv-file-browser which uses the Windows dir command to parse native directories + This behaves near identically to the native parser, but IO is done asynchronously. + + Available at: https://github.com/CogentRedTester/mpv-file-browser/tree/master/addons +]]-- + +local mp = require "mp" +local msg = require "mp.msg" +local fb = require "file-browser" + +local PLATFORM = fb.get_platform() + +---@param bytes string +---@return fun(): number, number +local function byte_iterator(bytes) + ---@async + ---@return number? + local function iter() + for i = 1, #bytes do + coroutine.yield(bytes:byte(i), i) + end + error('malformed utf16le string - expected byte but found end of string') + end + + return coroutine.wrap(iter) +end + +---@param bits number +---@param by number +---@return number +local function lshift(bits, by) + return bits * 2^by +end + +---@param bits number +---@param by number +---@return integer +local function rshift(bits, by) + return math.floor(bits / 2^by) +end + +---@param bits number +---@param i number +---@return number +local function bits_below(bits, i) + return bits % 2^i +end + +---@param bits number +---@param i number exclusive +---@param j number inclusive +---@return integer +local function bits_between(bits, i, j) + return rshift(bits_below(bits, j), i) +end + +---@param bytes string +---@return number[] +local function utf16le_to_unicode(bytes) + msg.trace('converting from utf16-le to unicode codepoints') + + ---@type number[] + local codepoints = {} + + local get_byte = byte_iterator(bytes) + + while true do + -- start of a char + local success, little, i = pcall(get_byte) + if not success then break end + + local big = get_byte() + local codepoint = little + lshift(big, 8) + + if codepoint < 0xd800 or codepoint > 0xdfff then + table.insert(codepoints, codepoint) + else + -- handling surrogate pairs + -- grab the next two bytes to grab the low surrogate + local high_pair = codepoint + local low_pair = get_byte() + lshift(get_byte(), 8) + + if high_pair >= 0xdc00 then + error(('malformed utf16le string at byte #%d (0x%04X) - high surrogate pair should be < 0xDC00'):format(i, high_pair)) + elseif low_pair < 0xdc00 then + error(('malformed utf16le string at byte #%d (0x%04X) - low surrogate pair should be >= 0xDC00'):format(i+2, low_pair)) + end + + -- The last 10 bits of each surrogate are the two halves of the codepoint + -- https://en.wikipedia.org/wiki/UTF-16#Code_points_from_U+010000_to_U+10FFFF + local high_bits = bits_below(high_pair, 10) + local low_bits = bits_below(low_pair, 10) + local surrogate_par = (low_bits + lshift(high_bits, 10)) + 0x10000 + + table.insert(codepoints, surrogate_par) + end + end + + return codepoints +end + +---@param codepoints number[] +---@return string +local function unicode_to_utf8(codepoints) + ---@type number[] + local bytes = {} + + -- https://en.wikipedia.org/wiki/UTF-8#Description + for i, codepoint in ipairs(codepoints) do + if codepoint >= 0xd800 and codepoint <= 0xdfff then + error(('codepoint %d (U+%05X) is within the reserved surrogate pair range (U+D800-U+DFFF)'):format(i, codepoint)) + elseif codepoint <= 0x7f then + table.insert(bytes, codepoint) + elseif codepoint <= 0x7ff then + table.insert(bytes, 0xC0 + rshift(codepoint, 6)) + table.insert(bytes, 0x80 + bits_below(codepoint, 6)) + elseif codepoint <= 0xffff then + table.insert(bytes, 0xE0 + rshift(codepoint, 12)) + table.insert(bytes, 0x80 + bits_between(codepoint, 6, 12)) + table.insert(bytes, 0x80 + bits_below(codepoint, 6)) + elseif codepoint <= 0x10ffff then + table.insert(bytes, 0xF0 + rshift(codepoint, 18)) + table.insert(bytes, 0x80 + bits_between(codepoint, 12, 18)) + table.insert(bytes, 0x80 + bits_between(codepoint, 6, 12)) + table.insert(bytes, 0x80 + bits_below(codepoint, 6)) + else + error(('codepoint %d (U+%05X) is larger than U+10FFFF'):format(i, codepoint)) + end + end + + return string.char(table.unpack(bytes)) +end + +local function utf8(text) + return unicode_to_utf8(utf16le_to_unicode(text)) +end + +---@type ParserConfig +local dir = { + priority = 109, + api_version = "1.9.0", + name = "cmd-dir", + keybind_name = "file" +} + +---@async +---@param args string[] +---@param parse_state ParseState +---@return string|nil +local function command(args, parse_state) + local async = mp.command_native_async({ + name = "subprocess", + playback_only = false, + capture_stdout = true, + capture_stderr = true, + args = args, + }, fb.coroutine.callback(30) ) + + ---@type boolean, boolean, MPVSubprocessResult + local completed, _, cmd = parse_state:yield() + if not completed then + msg.warn('read timed out for:', table.unpack(args)) + mp.abort_async_command(async) + return nil + end + + local success = xpcall(function() + cmd.stdout = utf8(cmd.stdout) or '' + cmd.stderr = utf8(cmd.stderr) or '' + end, fb.traceback) + + if not success then return msg.error('failed to convert utf16-le string to utf8') end + + --dir returns this exact error message if the directory is empty + if cmd.status == 1 and cmd.stderr == "File Not Found\r\n" then cmd.status = 0 end + if cmd.status ~= 0 then return msg.error(cmd.stderr) end + + return cmd.status == 0 and cmd.stdout or nil +end + +function dir:can_parse(directory) + if not fb.get_opt('windir_parser') then return false end + return PLATFORM == 'windows' and directory ~= '' and not fb.get_protocol(directory) +end + +---@async +function dir:parse(directory, parse_state) + local list = {} + + -- the dir command expects backslashes for our paths + directory = string.gsub(directory, "/", "\\") + + local dirs = command({ "cmd", "/U", "/c", "dir", "/b", "/ad", directory }, parse_state) + if not dirs then return end + + local files = command({ "cmd", "/U", "/c", "dir", "/b", "/a-d", directory }, parse_state) + if not files then return end + + for name in dirs:gmatch("[^\n\r]+") do + name = name.."/" + if fb.valid_dir(name) then + table.insert(list, { name = name, type = "dir" }) + msg.trace(name) + end + end + + for name in files:gmatch("[^\n\r]+") do + if fb.valid_file(name) then + table.insert(list, { name = name, type = "file" }) + msg.trace(name) + end + end + + return list, { filtered = true } +end + +return dir diff --git a/mpv/scripts/file-browser/modules/addons/winroot.lua b/mpv/scripts/file-browser/modules/addons/winroot.lua new file mode 100644 index 0000000..0b046e8 --- /dev/null +++ b/mpv/scripts/file-browser/modules/addons/winroot.lua @@ -0,0 +1,62 @@ +--[[ + This file is an internal file-browser addon. + It should not be imported like a normal module. + + Automatically populates the root with windows drives on startup. + Ctrl+r will add new drives mounted since startup. + + Drives will only be added if they are not already present in the root. +]] + +local mp = require 'mp' +local msg = require 'mp.msg' +local fb = require 'file-browser' + +local PLATFORM = fb.get_platform() + +---returns a list of windows drives +---@return string[]? +local function get_drives() + ---@type MPVSubprocessResult?, string? + local result, err = mp.command_native({ + name = 'subprocess', + playback_only = false, + capture_stdout = true, + args = {'fsutil', 'fsinfo', 'drives'} + }) + if not result then return msg.error(err) end + if result.status ~= 0 then return msg.error('could not read windows root') end + + local root = {} + for drive in result.stdout:gmatch("(%a:)\\") do + table.insert(root, drive..'/') + end + return root +end + +-- adds windows drives to the root if they are not already present +local function import_drives() + if fb.get_opt('auto_detect_windows_drives') and PLATFORM ~= 'windows' then return end + + local drives = get_drives() + if not drives then return end + + for _, drive in ipairs(drives) do + fb.register_root_item(drive) + end +end + +local keybind = { + key = 'Ctrl+r', + name = 'import_root_drives', + command = import_drives, + parser = 'root', + passthrough = true +} + +---@type ParserConfig +return { + api_version = '1.9.0', + setup = import_drives, + keybinds = { keybind } +} |
