------------------------------------------------------------------------------------------ ---------------------------------File/Playlist Opening------------------------------------ ------------------------------------------------------------------------------------------ ------------------------------------------------------------------------------------------ local mp = require 'mp' local msg = require 'mp.msg' local utils = require 'mp.utils' local o = require 'modules.options' local g = require 'modules.globals' local fb_utils = require 'modules.utils' local ass = require 'modules.ass' local cursor = require 'modules.navigation.cursor' local controls = require 'modules.controls' local scanning = require 'modules.navigation.scanning' local movement = require 'modules.navigation.directory-movement' local state = g.state ---@alias LoadfileFlag 'replace'|'append-play' ---@class LoadOpts ---@field directory string ---@field flag LoadfileFlag ---@field autoload boolean ---@field items_appended number ---@field co thread ---@field concurrency number ---In mpv v0.38 a new index argument was added to the loadfile command. ---For some crazy reason this new argument is placed before the existing options ---argument, breaking any scripts that used it. This function finds the correct index ---for the options argument using the `command-list` property. ---@return integer local function get_loadfile_options_arg_index() ---@type table[] local command_list = mp.get_property_native('command-list', {}) for _, command in ipairs(command_list) do if command.name == 'loadfile' then for i, arg in ipairs(command.args or {} --[=[@as table[]]=]) do if arg.name == 'options' then return i end end end end return 3 end local LEGACY_LOADFILE_SYNTAX = get_loadfile_options_arg_index() == 3 ---A wrapper around loadfile to handle the syntax changes introduced in mpv v0.38. ---@param file string ---@param flag string ---@param options? string|table ---@return boolean local function legacy_loadfile_wrapper(file, flag, options) if LEGACY_LOADFILE_SYNTAX then return mp.command_native({"loadfile", file, flag, options}) ~= nil else return mp.command_native({"loadfile", file, flag, -1, options}) ~= nil end end ---Adds a file to the playlist and changes the flag to `append-play` in preparation for future items. ---@param file string ---@param opts LoadOpts ---@param mpv_opts? string|table local function loadfile(file, opts, mpv_opts) if o.substitute_backslash and not fb_utils.get_protocol(file) then file = string.gsub(file, "/", "\\") end if opts.flag == "replace" then msg.verbose("Playling file", file) else msg.verbose("Appending", file, "to the playlist") end if mpv_opts then msg.debug('Settings opts on', file, ':', utils.to_string(mpv_opts)) end if not legacy_loadfile_wrapper(file, opts.flag, mpv_opts) then msg.warn(file) end if opts.flag == 'replace' and mp.get_property_bool('pause') then mp.set_property_bool('pause', false) end opts.flag = "append-play" opts.items_appended = opts.items_appended + 1 end ---@diagnostic disable-next-line no-unknown local concurrent_loadlist_wrapper ---@alias ConcurrentRefMap table ---This function recursively loads directories concurrently in separate coroutines. ---Results are saved in a tree of tables that allows asynchronous access. ---@async ---@param directory string ---@param load_opts LoadOpts ---@param prev_dirs Set ---@param item_t Item ---@param refs ConcurrentRefMap ---@return boolean? local function concurrent_loadlist_parse(directory, load_opts, prev_dirs, item_t, refs) if not refs[item_t] then refs[item_t] = {} end --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 = scanning.scan_directory(directory, { source = 'loadlist' }) if list == g.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") refs[item_t].recurse = false return end directory = list_opts.directory or directory --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) refs[item_t].sublist = list or {} refs[list] = {directory = directory} if directory == "" then return end --launches new parse operations for directories, each in a different coroutine for _, item in ipairs(list) do if fb_utils.parseable_item(item) then fb_utils.coroutine.run(concurrent_loadlist_wrapper, fb_utils.get_new_directory(item, directory), load_opts, prev_dirs, item, refs) end end return true end ---A wrapper function that ensures the concurrent_loadlist_parse is run correctly. ---@async ---@param directory string ---@param opts LoadOpts ---@param prev_dirs Set ---@param item Item ---@param refs ConcurrentRefMap function concurrent_loadlist_wrapper(directory, opts, prev_dirs, item, refs) --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 fb_utils.coroutine.sleep(0.1) end opts.concurrency = opts.concurrency + 1 local success = concurrent_loadlist_parse(directory, opts, prev_dirs, item, refs) opts.concurrency = opts.concurrency - 1 if not success then refs[item].sublist = {} end if coroutine.status(opts.co) == "suspended" then fb_utils.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. ---@async ---@param list List ---@param load_opts LoadOpts ---@param refs ConcurrentRefMap local function concurrent_loadlist_append(list, load_opts, refs) local directory = refs[list].directory for _, item in ipairs(list) do if not g.sub_extensions[ fb_utils.get_extension(item.name, "") ] and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ] then while fb_utils.parseable_item(item) and (not refs[item] or not refs[item].sublist) do coroutine.yield() end if fb_utils.parseable_item(item) and refs[item] ~= false then concurrent_loadlist_append(refs[item].sublist, load_opts, refs) else loadfile(fb_utils.get_full_path(item, directory), load_opts, item.mpv_options) end end end end ---Recursive function to load directories serially. ---Returns true if any items were appended to the playlist. ---@async ---@param directory string ---@param load_opts LoadOpts ---@param prev_dirs Set ---@return true|nil 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 = scanning.scan_directory(directory, { source = "loadlist" }) if list == g.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) return true end directory = opts.directory or directory if directory == "" then return end for _, item in ipairs(list) do if not g.sub_extensions[ fb_utils.get_extension(item.name, "") ] and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ] then if fb_utils.parseable_item(item) then custom_loadlist_recursive( fb_utils.get_new_directory(item, directory) , load_opts, prev_dirs) else local path = fb_utils.get_full_path(item, directory) loadfile(path, load_opts, item.mpv_options) end end end end ---A wrapper for the custom_loadlist_recursive function. ---@async ---@param item Item ---@param opts LoadOpts local function loadlist(item, opts) local dir = fb_utils.get_full_path(item, opts.directory) local num_items = opts.items_appended if o.concurrent_recursion then item = fb_utils.copy_table(item) opts.co = fb_utils.coroutine.assert() opts.concurrency = 0 ---@type List local v_list = {item} ---@type ConcurrentRefMap local refs = setmetatable({[v_list] = {directory = opts.directory}}, {__mode = 'k'}) --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 fb_utils.coroutine.queue(concurrent_loadlist_wrapper, dir, opts, {}, item, refs) concurrent_loadlist_append(v_list, opts, refs) 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. ---@param path string ---@param opts LoadOpts local function autoload_dir(path, opts) if o.autoload_save_current and path == g.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 g.sub_extensions[ fb_utils.get_extension(item.name, "") ] and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ] then local p = fb_utils.get_full_path(item) if p == path then pos = file_count else loadfile( p, opts, item.mpv_options) end file_count = file_count + 1 end end mp.commandv("playlist-move", 0, pos+1) end ---Runs the loadfile or loadlist command. ---@async ---@param item Item ---@param opts LoadOpts ---@return nil local function open_item(item, opts) if fb_utils.parseable_item(item) then return loadlist(item, opts) end local path = fb_utils.get_full_path(item, opts.directory) if g.sub_extensions[ fb_utils.get_extension(item.name, "") ] then mp.commandv("sub-add", path, opts.flag == "replace" and "select" or "auto") elseif g.audio_extensions[ fb_utils.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, item.mpv_options) 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. ---@async ---@param opts LoadOpts ---@return nil local function open_file_coroutine(opts) if not state.list[state.selected] then return end if opts.flag == 'replace' then controls.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 = fb_utils.sort_keys(state.selection) --reset the selection after state.selection = {} cursor.disable_select_mode() ass.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 movement.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) ---@type LoadOpts local opts = { flag = flag, autoload = (autoload ~= o.autoload and flag == "replace"), directory = state.directory, items_appended = 0, concurrency = 0, co = coroutine.create(open_file_coroutine) } fb_utils.coroutine.resume_err(opts.co, opts) end ---@class playlist return { add_files = open_file, }