This commit is contained in:
2026-03-27 07:06:16 +01:00
commit 1541961403
340 changed files with 151916 additions and 0 deletions
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 }
}