nirish

config files for niri window manager
Log | Files | Refs | README | LICENSE

playlist.lua (13333B)


      1 ------------------------------------------------------------------------------------------
      2 ---------------------------------File/Playlist Opening------------------------------------
      3 ------------------------------------------------------------------------------------------
      4 ------------------------------------------------------------------------------------------
      5 
      6 local mp = require 'mp'
      7 local msg = require 'mp.msg'
      8 local utils = require 'mp.utils'
      9 
     10 local o = require 'modules.options'
     11 local g = require 'modules.globals'
     12 local fb_utils = require 'modules.utils'
     13 local ass = require 'modules.ass'
     14 local cursor = require 'modules.navigation.cursor'
     15 local controls = require 'modules.controls'
     16 local scanning = require 'modules.navigation.scanning'
     17 local movement = require 'modules.navigation.directory-movement'
     18 
     19 local state = g.state
     20 
     21 ---@alias LoadfileFlag 'replace'|'append-play'
     22 
     23 ---@class LoadOpts
     24 ---@field directory string
     25 ---@field flag LoadfileFlag
     26 ---@field autoload boolean
     27 ---@field items_appended number
     28 ---@field co thread
     29 ---@field concurrency number
     30 
     31 ---In mpv v0.38 a new index argument was added to the loadfile command.
     32 ---For some crazy reason this new argument is placed before the existing options
     33 ---argument, breaking any scripts that used it. This function finds the correct index
     34 ---for the options argument using the `command-list` property.
     35 ---@return integer
     36 local function get_loadfile_options_arg_index()
     37     ---@type table[]
     38     local command_list = mp.get_property_native('command-list', {})
     39     for _, command in ipairs(command_list) do
     40         if command.name == 'loadfile' then
     41             for i, arg in ipairs(command.args or {} --[=[@as table[]]=]) do
     42                 if arg.name == 'options' then
     43                     return i
     44                 end
     45             end
     46         end
     47     end
     48 
     49     return 3
     50 end
     51 
     52 local LEGACY_LOADFILE_SYNTAX = get_loadfile_options_arg_index() == 3
     53 
     54 ---A wrapper around loadfile to handle the syntax changes introduced in mpv v0.38.
     55 ---@param file string
     56 ---@param flag string
     57 ---@param options? string|table<string,unknown>
     58 ---@return boolean
     59 local function legacy_loadfile_wrapper(file, flag, options)
     60     if LEGACY_LOADFILE_SYNTAX then
     61         return mp.command_native({"loadfile", file, flag, options}) ~= nil
     62     else
     63         return mp.command_native({"loadfile", file, flag, -1, options}) ~= nil
     64     end
     65 end
     66 
     67 ---Adds a file to the playlist and changes the flag to `append-play` in preparation for future items.
     68 ---@param file string
     69 ---@param opts LoadOpts
     70 ---@param mpv_opts? string|table<string,unknown>
     71 local function loadfile(file, opts, mpv_opts)
     72     if o.substitute_backslash and not fb_utils.get_protocol(file) then
     73         file = string.gsub(file, "/", "\\")
     74     end
     75 
     76     if opts.flag == "replace" then msg.verbose("Playling file", file)
     77     else msg.verbose("Appending", file, "to the playlist") end
     78 
     79     if mpv_opts then
     80         msg.debug('Settings opts on', file, ':', utils.to_string(mpv_opts))
     81     end
     82 
     83     if not legacy_loadfile_wrapper(file, opts.flag, mpv_opts) then msg.warn(file) end
     84     opts.flag = "append-play"
     85     opts.items_appended = opts.items_appended + 1
     86 end
     87 
     88 ---@diagnostic disable-next-line no-unknown
     89 local concurrent_loadlist_wrapper
     90 
     91 ---@alias ConcurrentRefMap table<List|Item,{directory: string?, sublist: List?, recurse: boolean?}>
     92 
     93 ---This function recursively loads directories concurrently in separate coroutines.
     94 ---Results are saved in a tree of tables that allows asynchronous access.
     95 ---@async
     96 ---@param directory string
     97 ---@param load_opts LoadOpts
     98 ---@param prev_dirs Set<string>
     99 ---@param item_t Item
    100 ---@param refs ConcurrentRefMap
    101 ---@return boolean?
    102 local function concurrent_loadlist_parse(directory, load_opts, prev_dirs, item_t, refs)
    103     if not refs[item_t] then refs[item_t] = {} end
    104 
    105     --prevents infinite recursion from the item.path or opts.directory fields
    106     if prev_dirs[directory] then return end
    107     prev_dirs[directory] = true
    108 
    109     local list, list_opts = scanning.scan_directory(directory, { source = 'loadlist' })
    110     if list == g.root then return end
    111 
    112     --if we can't parse the directory then append it and hope mpv fares better
    113     if list == nil then
    114         msg.warn("Could not parse", directory, "appending to playlist anyway")
    115         refs[item_t].recurse = false
    116         return
    117     end
    118 
    119     directory = list_opts.directory or directory
    120 
    121     --we must declare these before we start loading sublists otherwise the append thread will
    122     --need to wait until the whole list is loaded (when synchronous IO is used)
    123     refs[item_t].sublist = list or {}
    124     refs[list] = {directory = directory}
    125 
    126     if directory == "" then return end
    127 
    128     --launches new parse operations for directories, each in a different coroutine
    129     for _, item in ipairs(list) do
    130         if fb_utils.parseable_item(item) then
    131             fb_utils.coroutine.run(concurrent_loadlist_wrapper, fb_utils.get_new_directory(item, directory), load_opts, prev_dirs, item, refs)
    132         end
    133     end
    134     return true
    135 end
    136 
    137 ---A wrapper function that ensures the concurrent_loadlist_parse is run correctly.
    138 ---@async
    139 ---@param directory string
    140 ---@param opts LoadOpts
    141 ---@param prev_dirs Set<string>
    142 ---@param item Item
    143 ---@param refs ConcurrentRefMap
    144 function concurrent_loadlist_wrapper(directory, opts, prev_dirs, item, refs)
    145     --ensures that only a set number of concurrent parses are operating at any one time.
    146     --the mpv event queue is seemingly limited to 1000 items, but only async mpv actions like
    147     --command_native_async should use that, events like mp.add_timeout (which coroutine.sleep() uses) should
    148     --be handled enturely on the Lua side with a table, which has a significantly larger maximum size.
    149     while (opts.concurrency > o.max_concurrency) do
    150         fb_utils.coroutine.sleep(0.1)
    151     end
    152     opts.concurrency = opts.concurrency + 1
    153 
    154     local success = concurrent_loadlist_parse(directory, opts, prev_dirs, item, refs)
    155     opts.concurrency = opts.concurrency - 1
    156     if not success then refs[item].sublist = {} end
    157     if coroutine.status(opts.co) == "suspended" then fb_utils.coroutine.resume_err(opts.co) end
    158 end
    159 
    160 ---Recursively appends items to the playlist, acts as a consumer to the previous functions producer;
    161 ---If the next directory has not been parsed this function will yield until the parse has completed.
    162 ---@async
    163 ---@param list List
    164 ---@param load_opts LoadOpts
    165 ---@param refs ConcurrentRefMap
    166 local function concurrent_loadlist_append(list, load_opts, refs)
    167     local directory = refs[list].directory
    168 
    169     for _, item in ipairs(list) do
    170         if not g.sub_extensions[ fb_utils.get_extension(item.name, "") ]
    171         and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ]
    172         then
    173             while fb_utils.parseable_item(item) and (not refs[item] or not refs[item].sublist) do
    174                 coroutine.yield()
    175             end
    176 
    177             if fb_utils.parseable_item(item) and refs[item] ~= false then
    178                 concurrent_loadlist_append(refs[item].sublist, load_opts, refs)
    179             else
    180                 loadfile(fb_utils.get_full_path(item, directory), load_opts, item.mpv_options)
    181             end
    182         end
    183     end
    184 end
    185 
    186 ---Recursive function to load directories serially.
    187 ---Returns true if any items were appended to the playlist.
    188 ---@async
    189 ---@param directory string
    190 ---@param load_opts LoadOpts
    191 ---@param prev_dirs Set<string>
    192 ---@return true|nil
    193 local function custom_loadlist_recursive(directory, load_opts, prev_dirs)
    194     --prevents infinite recursion from the item.path or opts.directory fields
    195     if prev_dirs[directory] then return end
    196     prev_dirs[directory] = true
    197 
    198     local list, opts = scanning.scan_directory(directory, { source = "loadlist" })
    199     if list == g.root then return end
    200 
    201     --if we can't parse the directory then append it and hope mpv fares better
    202     if list == nil then
    203         msg.warn("Could not parse", directory, "appending to playlist anyway")
    204         loadfile(directory, load_opts)
    205         return true
    206     end
    207 
    208     directory = opts.directory or directory
    209     if directory == "" then return end
    210 
    211     for _, item in ipairs(list) do
    212         if not g.sub_extensions[ fb_utils.get_extension(item.name, "") ]
    213         and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ]
    214         then
    215             if fb_utils.parseable_item(item) then
    216                 custom_loadlist_recursive( fb_utils.get_new_directory(item, directory) , load_opts, prev_dirs)
    217             else
    218                 local path = fb_utils.get_full_path(item, directory)
    219                 loadfile(path, load_opts, item.mpv_options)
    220             end
    221         end
    222     end
    223 end
    224 
    225 
    226 ---A wrapper for the custom_loadlist_recursive function.
    227 ---@async
    228 ---@param item Item
    229 ---@param opts LoadOpts
    230 local function loadlist(item, opts)
    231     local dir = fb_utils.get_full_path(item, opts.directory)
    232     local num_items = opts.items_appended
    233 
    234     if o.concurrent_recursion then
    235         item = fb_utils.copy_table(item)
    236         opts.co = fb_utils.coroutine.assert()
    237         opts.concurrency = 0
    238 
    239         ---@type List
    240         local v_list = {item}
    241         ---@type ConcurrentRefMap
    242         local refs = setmetatable({[v_list] = {directory = opts.directory}}, {__mode = 'k'})
    243 
    244         --we need the current coroutine to suspend before we run the first parse operation, so
    245         --we schedule the coroutine to run on the mpv event queue
    246         fb_utils.coroutine.queue(concurrent_loadlist_wrapper, dir, opts, {}, item, refs)
    247         concurrent_loadlist_append(v_list, opts, refs)
    248     else
    249         custom_loadlist_recursive(dir, opts, {})
    250     end
    251 
    252     if opts.items_appended == num_items then msg.warn(dir, "contained no valid files") end
    253 end
    254 
    255 ---Load playlist entries before and after the currently playing file.
    256 ---@param path string
    257 ---@param opts LoadOpts
    258 local function autoload_dir(path, opts)
    259     if o.autoload_save_current and path == g.current_file.path then
    260         mp.commandv("write-watch-later-config") end
    261 
    262     --loads the currently selected file, clearing the playlist in the process
    263     loadfile(path, opts)
    264 
    265     local pos = 1
    266     local file_count = 0
    267     for _,item in ipairs(state.list) do
    268         if item.type == "file" 
    269         and not g.sub_extensions[ fb_utils.get_extension(item.name, "") ]
    270         and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ]
    271         then
    272             local p = fb_utils.get_full_path(item)
    273 
    274             if p == path then pos = file_count
    275             else loadfile( p, opts, item.mpv_options) end
    276 
    277             file_count = file_count + 1
    278         end
    279     end
    280     mp.commandv("playlist-move", 0, pos+1)
    281 end
    282 
    283 ---Runs the loadfile or loadlist command.
    284 ---@async
    285 ---@param item Item
    286 ---@param opts LoadOpts
    287 ---@return nil
    288 local function open_item(item, opts)
    289     if fb_utils.parseable_item(item) then
    290         return loadlist(item, opts)
    291     end
    292 
    293     local path = fb_utils.get_full_path(item, opts.directory)
    294     if g.sub_extensions[ fb_utils.get_extension(item.name, "") ] then
    295         mp.commandv("sub-add", path, opts.flag == "replace" and "select" or "auto")
    296     elseif g.audio_extensions[ fb_utils.get_extension(item.name, "") ] then
    297         mp.commandv("audio-add", path, opts.flag == "replace" and "select" or "auto")
    298     else
    299         if opts.autoload then autoload_dir(path, opts)
    300         else loadfile(path, opts, item.mpv_options) end
    301     end
    302 end
    303 
    304 ---Handles the open options as a coroutine.
    305 ---Once loadfile has been run we can no-longer guarantee synchronous execution - the state values may change
    306 ---therefore, we must ensure that any state values that could be used after a loadfile call are saved beforehand.
    307 ---@async
    308 ---@param opts LoadOpts
    309 ---@return nil
    310 local function open_file_coroutine(opts)
    311     if not state.list[state.selected] then return end
    312     if opts.flag == 'replace' then controls.close() end
    313 
    314     --we want to set the idle option to yes to ensure that if the first item
    315     --fails to load then the player has a chance to attempt to load further items (for async append operations)
    316     local idle = mp.get_property("idle", "once")
    317     mp.set_property("idle", "yes")
    318 
    319     --handles multi-selection behaviour
    320     if next(state.selection) then
    321         local selection = fb_utils.sort_keys(state.selection)
    322         --reset the selection after
    323         state.selection = {}
    324 
    325         cursor.disable_select_mode()
    326         ass.update_ass()
    327 
    328         --the currently selected file will be loaded according to the flag
    329         --the flag variable will be switched to append once a file is loaded
    330         for i=1, #selection do
    331             open_item(selection[i], opts)
    332         end
    333 
    334     else
    335         local item = state.list[state.selected]
    336         if opts.flag == "replace" then movement.down_dir() end
    337         open_item(item, opts)
    338     end
    339 
    340     if mp.get_property("idle") == "yes" then mp.set_property("idle", idle) end
    341 end
    342 
    343 --opens the selelected file(s)
    344 local function open_file(flag, autoload)
    345     ---@type LoadOpts
    346     local opts = {
    347         flag = flag,
    348         autoload = (autoload ~= o.autoload and flag == "replace"),
    349         directory = state.directory,
    350         items_appended = 0,
    351         concurrency = 0,
    352         co = coroutine.create(open_file_coroutine)
    353     }
    354     fb_utils.coroutine.resume_err(opts.co, opts)
    355 end
    356 
    357 ---@class playlist
    358 return {
    359     add_files = open_file,
    360 }