dotfiles

My personal shell configs and stuff
git clone git://git.alex.balgavy.eu/dotfiles.git
Log | Files | Refs | Submodules | README | LICENSE

navigator.lua (16107B)


      1 local utils = require("mp.utils")
      2 local mpopts = require("mp.options")
      3 local assdraw = require("mp.assdraw")
      4 
      5 ON_WINDOWS = (package.config:sub(1,1) ~= "/")
      6 WINDOWS_ROOTDIR = false
      7 WINDOWS_ROOT_DESC = "Select drive"
      8 SEPARATOR_WINDOWS = "\\"
      9 
     10 SEPARATOR = "/"
     11 
     12 local windows_desktop = ON_WINDOWS and utils.join_path(os.getenv("USERPROFILE"), "Desktop"):gsub(SEPARATOR, SEPARATOR_WINDOWS)..SEPARATOR_WINDOWS or nil
     13 
     14 local settings = {
     15   --navigation keybinds override arrowkeys and enter when activating navigation menu, false means keys are always actíve
     16   dynamic_binds = true,
     17   navigator_mainkey = "Alt+f", --the key to bring up navigator's menu, can be bound on input.conf aswell
     18 
     19   --dynamic binds, should not be bound in input.conf unless dynamic binds is false
     20   key_navfavorites = "f",
     21   key_navup = "UP",
     22   key_navdown = "DOWN",
     23   key_navback = "LEFT",
     24   key_navforward = "RIGHT",
     25   key_navopen = "ENTER",
     26   key_navclose = "ESC",
     27 
     28   --fallback if no file is open, should be a string that points to a path in your system
     29   defaultpath = windows_desktop or os.getenv("HOME") or "/",
     30   forcedefault = false, --force navigation to start from defaultpath instead of currently playing file
     31   --favorites in format { 'Path to directory, notice trailing /' }
     32   --on windows use double backslash c:\\my\\directory\\
     33   favorites = {
     34     '/Volumes/HDD/Movies/',
     35     '/Volumes/HDD/Destroy All Software/',
     36     '/Volumes/HDD/Videos/'
     37   },
     38   --list of paths to ignore. the value is anything that returns true for if-statement.
     39   --directory ignore entries must end with a trailing slash,
     40   --but files and all symlinks (even to dirs) must be without slash!
     41   --to help you with the format, simply run "ls -1p <parent folder>" in a terminal,
     42   --and you will see if the file/folder to ignore is listed as "file" or "folder/" (trailing slash).
     43   --you can ignore children without ignoring their parent.
     44   ignorePaths = {
     45     --general linux system paths (some are used by macOS too):
     46     ['/bin/']='1',['/boot/']='1',['/cdrom/']='1',['/dev/']='1',['/etc/']='1',['/lib/']='1',['/lib32/']='1',['/lib64/']='1',['/tmp/']='1',
     47     ['/srv/']='1',['/sys/']='1',['/snap/']='1',['/root/']='1',['/sbin/']='1',['/proc/']='1',['/opt/']='1',['/usr/']='1',['/run/']='1',
     48     --useless macOS system paths (some of these standard folders are actually files (symlinks) into /private/ subpaths, hence some repetition):
     49     ['/cores/']='1',['/etc']='1',['/installer.failurerequests']='1',['/net/']='1',['/private/']='1',['/tmp']='1',['/var']='1'
     50   },
     51   --ignore folders and files that match patterns regardless of where they exist on disk.
     52   --make sure you use ^ (start of string) and $ (end of string) to catch the whole str instead of risking partial false positives.
     53   --read about patterns at https://www.lua.org/pil/20.2.html or http://lua-users.org/wiki/PatternsTutorial
     54   ignorePatterns = {
     55     '^initrd%..*/?$', --hide files and folders folders starting with "initrd.<something>"
     56     '^vmlinuz.*/?$', --hide files and folders starting with "vmlinuz<something>"
     57     '^lost%+found/?$', --hide files and folders named "lost+found"
     58     '^.*%.log$', --ignore files with extension .log
     59     '^%$.*$', --ignore files starting with $
     60   },
     61 
     62   subtitleformats = {
     63     'srt', 'ass', 'lrc', 'ssa', 'ttml', 'sbv', 'vtt', 'txt'
     64   },
     65 
     66   navigator_menu_favkey = "f", --this key will always be bound when the menu is open, and is the key you use to cycle your favorites list!
     67   menu_timeout = true,         --menu timeouts and closes itself after navigator_duration seconds, else will be toggled by keybind
     68   navigator_duration = 13,     --osd duration before the navigator closes, if timeout is set to true
     69   visible_item_count = 10,     --how many menu items to show per screen
     70 
     71   --font size scales by window, if false requires larger font and padding sizes
     72   scale_by_window = true,
     73   --paddings from top left corner
     74   text_padding_x = 10,
     75   text_padding_y = 30,
     76   --ass style overrides inside curly brackets, \keyvalue is one field, extra \ for escape in lua
     77   --example {\\fnUbuntu\\fs10\\b0\\bord1} equals: font=Ubuntu, size=10, bold=no, border=1
     78   --read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags
     79   --undeclared tags will use default osd settings
     80   --these styles will be used for the whole navigator
     81   style_ass_tags = "{}",
     82   --you can also use the ass tags mentioned above. For example:
     83   --selection_prefix="{\\c&HFF00FF&}● " - to add a color for selected file. However, if you
     84   --use ass tags you need to set them for both name and selection prefix (see https://github.com/jonniek/mpv-playlistmanager/issues/20)
     85   name_prefix = "○ ",
     86   selection_prefix = "● ",
     87 }
     88 
     89 mpopts.read_options(settings)
     90 
     91 --escape a file or directory path for use in shell arguments
     92 function escapepath(dir, escapechar)
     93   return string.gsub(dir, escapechar, '\\'..escapechar)
     94 end
     95 
     96 local sub_lookup = {}
     97 for _, ext in ipairs(settings.subtitleformats) do
     98   sub_lookup[ext] = true
     99 end
    100 
    101 
    102 --ensures directories never accidentally end in "//" due to our added slash
    103 function stripdoubleslash(dir)
    104   if (string.sub(dir, -2) == "//") then
    105     return string.sub(dir, 1, -2) --negative 2 removes the last character
    106   else
    107     return dir
    108   end
    109 end
    110 
    111 function os.capture(cmd, raw)
    112   local f = assert(io.popen(cmd, 'r'))
    113   local s = assert(f:read('*a'))
    114   f:close()
    115   return string.sub(s, 0, -2)
    116 end
    117 
    118 dir = nil
    119 path = nil
    120 cursor = 0
    121 length = 0
    122 --osd handler that displays your navigation and information
    123 function handler()
    124   add_keybinds()
    125   timer:kill()
    126   local ass = assdraw.ass_new()
    127   ass:new_event()
    128   ass:pos(settings.text_padding_x, settings.text_padding_y)
    129   ass:append(settings.style_ass_tags)
    130 
    131   if not path then
    132     if mp.get_property('path') and not settings.forcedefault then
    133       --determine path from currently playing file...
    134       local workingdir = mp.get_property("working-directory")
    135       local playfilename = mp.get_property("filename") --just the filename, without path
    136       local playpath = mp.get_property("path") --can be relative or absolute depending on what args mpv was given
    137       local firstchar = string.sub(playpath, 1, 1)
    138       --first we need to remove the filename (may give us empty path if mpv was started in same dir as file)
    139       path = string.sub(playpath, 1, string.len(playpath)-string.len(playfilename))
    140       if (firstchar ~= "/" and not ON_WINDOWS) then --the path of the playing file wasn't absolute, so we need to add mpv's working dir to it
    141         path = workingdir.."/"..path
    142       end
    143       --now resolve that path (to resolve things like "/home/anon/Movies/../Movies/foo.mkv")
    144       path = resolvedir(path)
    145       --lastly, check if the folder exists, and if not then fall back to the current mpv working dir
    146       if (not isfolder(path)) then
    147         if ON_WINDOWS then
    148           path = workingdir..SEPARATOR_WINDOWS
    149         else
    150           path = workingdir
    151         end
    152       end
    153     else path = settings.defaultpath end
    154     dir,length = scandirectory(path)
    155   end
    156   ass:append(path.."\\N\\N")
    157   local b = cursor - math.floor(settings.visible_item_count / 2)
    158   if b > 0 then ass:append("...\\N") end
    159   if b < 0 then b=0 end
    160   for a=b,(b+settings.visible_item_count),1 do
    161     if a==length then break end
    162     local prefix = (a == cursor and settings.selection_prefix or settings.name_prefix)
    163     ass:append(prefix..dir[a].."\\N")
    164     if a == (b+settings.visible_item_count) then
    165       ass:append("...")
    166     end
    167   end
    168   local w, h = mp.get_osd_size()
    169   if settings.scale_by_window then w,h = 0, 0 end
    170   mp.set_osd_ass(w, h, ass.text)
    171   if settings.menu_timeout then
    172     timer:resume()
    173   end
    174 end
    175 
    176 function navdown()
    177   if cursor~=length-1 then
    178     cursor = cursor+1
    179   else
    180     cursor = 0
    181   end
    182   handler()
    183 end
    184 
    185 function navup()
    186   if cursor~=0 then
    187     cursor = cursor-1
    188   else
    189     cursor = length-1
    190   end
    191   handler()
    192 end
    193 
    194 --moves into selected directory, or appends to playlist incase of file
    195 function childdir()
    196   local item = dir[cursor]
    197 
    198   -- windows only
    199   if ON_WINDOWS then
    200     if WINDOWS_ROOTDIR then
    201       WINDOWS_ROOTDIR = false
    202     end
    203     if item then
    204       local newdir = utils.join_path(path, item):gsub(SEPARATOR, SEPARATOR_WINDOWS)..SEPARATOR_WINDOWS
    205       local info, error = utils.file_info(newdir)
    206 
    207       if info and info.is_dir then
    208         changepath(newdir)
    209       else
    210 
    211         if issubtitle(item) then
    212           loadsubs(utils.join_path(path, item))
    213         else
    214           mp.commandv("loadfile", utils.join_path(path, item), "append-play")
    215           mp.osd_message("Appended file to playlist: "..item)
    216         end
    217         handler()
    218       end
    219     end
    220 
    221     return
    222   end
    223 
    224   if item then
    225     if isfolder(utils.join_path(path, item)) then
    226       local newdir = stripdoubleslash(utils.join_path(path, dir[cursor].."/"))
    227       changepath(newdir)
    228     else
    229       if issubtitle(item) then
    230         loadsubs(utils.join_path(path, item))
    231       else
    232         mp.commandv("loadfile", utils.join_path(path, item), "append-play")
    233         mp.osd_message("Appended file to playlist: "..item)
    234       end
    235       handler()
    236     end
    237   end
    238 end
    239 
    240 function issubtitle(file)
    241   local ext = file:match("^.+%.(.+)$")
    242   return ext and sub_lookup[ext:lower()]
    243 end
    244 
    245 function loadsubs(file)
    246   mp.commandv("sub_add", file)
    247   mp.osd_message("Loaded subtitle: "..file)
    248 end
    249 
    250 --replace current playlist with directory or file
    251 --if directory, mpv will recursively queue all items found in the directory and its subfolders
    252 function opendir()
    253   local item = dir[cursor]
    254 
    255   if item then
    256     remove_keybinds()
    257 
    258     local filepath = utils.join_path(path, item)
    259     if ON_WINDOWS then
    260       filepath = filepath:gsub(SEPARATOR, SEPARATOR_WINDOWS)
    261     end
    262 
    263     if issubtitle(item) then
    264       return loadsubs(filepath)
    265     end
    266 
    267     mp.commandv("loadfile", filepath, "replace")
    268   end
    269 end
    270 
    271 --changes the directory to the path in argument
    272 function changepath(args)
    273   path = args
    274   if WINDOWS_ROOTDIR then
    275     path = WINDOWS_ROOT_DESC
    276   end
    277   dir,length = scandirectory(path)
    278   cursor=0
    279   handler()
    280 end
    281 
    282 --move up to the parent directory
    283 function parentdir()
    284   -- windows only
    285   if ON_WINDOWS then
    286     if path:sub(-1) == SEPARATOR_WINDOWS then
    287       path = path:sub(1, -2)
    288     end
    289     local parent = utils.split_path(path)
    290     if path == parent then
    291       WINDOWS_ROOTDIR = true
    292     end
    293     changepath(parent)
    294     return
    295   end
    296 
    297   --if path doesn't exist or can't be entered, this returns "/" (root of the drive) as the parent
    298   local parent = stripdoubleslash(os.capture('cd "'..escapepath(path, '"')..'" 2>/dev/null && cd .. 2>/dev/null && pwd').."/")
    299 
    300   changepath(parent)
    301 end
    302 
    303 --resolves relative paths such as "/home/foo/../foo/Music" (to "/home/foo/Music") if the folder exists!
    304 function resolvedir(dir)
    305   local safedir = escapepath(dir, '"')
    306 
    307   -- windows only
    308   if ON_WINDOWS then
    309     local resolved = stripdoubleslash(os.capture('cd /d "'..safedir..'" && cd'))
    310     return resolved..SEPARATOR_WINDOWS
    311   end
    312 
    313   --if dir doesn't exist or can't be entered, this returns "/" (root of the drive) as the resolved path
    314   local resolved = stripdoubleslash(os.capture('cd "'..safedir..'" 2>/dev/null && pwd').."/")
    315   return resolved
    316 end
    317 
    318 --true if path exists and is a folder, otherwise false
    319 function isfolder(dir)
    320   -- windows only
    321   if ON_WINDOWS then
    322     local info, error = utils.file_info(dir)
    323     return info and info.is_dir or nil
    324   end
    325 
    326   local lua51returncode, _, lua52returncode = os.execute('test -d "'..escapepath(dir, '"')..'"')
    327   return lua51returncode == 0 or lua52returncode == 0
    328 end
    329 
    330 function scandirectory(searchdir)
    331   local directory = {}
    332   --list all files, using universal utilities and flags available on both Linux and macOS
    333   --  ls: -1 = list one file per line, -p = append "/" indicator to the end of directory names, -v = display in natural order
    334   --  stderr messages are ignored by sending them to /dev/null
    335   --  hidden files ("." prefix) are skipped, since they exist everywhere and never contain media
    336   --  if we cannot list the contents (due to no permissions, etc), this returns an empty list
    337 
    338   -- windows only
    339   if ON_WINDOWS then
    340     -- handle drive letters
    341     if WINDOWS_ROOTDIR then
    342       local popen, err = io.popen("wmic logicaldisk get caption")
    343       local i = 0
    344       if popen then
    345         for direntry in popen:lines() do
    346           -- only single letter followed by colon (:) are valid
    347           if string.find(direntry, "^%a:") then
    348             direntry = string.sub(direntry, 1, 2)
    349             local matchedignore = false
    350             for k,pattern in pairs(settings.ignorePatterns) do
    351               if direntry:find(pattern) then
    352                 matchedignore = true
    353                 break --don't waste time scanning further patterns
    354               end
    355             end
    356             if not matchedignore and not settings.ignorePaths[path..direntry] then
    357               directory[i] = direntry
    358               i=i+1
    359             end
    360           end
    361         end
    362         popen:close()
    363       else
    364         mp.msg.error("Could not scan for files :"..(err or ""))
    365       end
    366 
    367       return directory, i
    368     end
    369 
    370     local i = 0
    371     local files = utils.readdir(searchdir)
    372 
    373     if not files then
    374       mp.msg.error("Could not scan for files :"..(err or ""))
    375       return directory, i
    376     end
    377 
    378     for _, direntry in ipairs(files) do
    379       local matchedignore = false
    380       for k,pattern in pairs(settings.ignorePatterns) do
    381         if direntry:find(pattern) then
    382           matchedignore = true
    383           break --don't waste time scanning further patterns
    384         end
    385       end
    386       if not matchedignore and not settings.ignorePaths[path..direntry] then
    387         directory[i] = direntry
    388         i=i+1
    389       end
    390     end
    391 
    392     return directory, i
    393   end
    394 
    395   local popen, err = io.popen('ls -1vp "'..escapepath(searchdir, '"')..'" 2>/dev/null')
    396   local i = 0
    397   if popen then
    398     for direntry in popen:lines() do
    399       local matchedignore = false
    400       for k,pattern in pairs(settings.ignorePatterns) do
    401         if direntry:find(pattern) then
    402           matchedignore = true
    403           break --don't waste time scanning further patterns
    404         end
    405       end
    406       if not matchedignore and not settings.ignorePaths[path..direntry] then
    407         directory[i] = direntry
    408         i=i+1
    409       end
    410     end
    411     popen:close()
    412   else
    413     mp.msg.error("Could not scan for files :"..(err or ""))
    414   end
    415   return directory, i
    416 end
    417 
    418 favcursor = 1
    419 function cyclefavorite()
    420   local firstpath = settings.favorites[1]
    421   if not firstpath then return end
    422   local favpath = nil
    423   local favlen = 0
    424   for key, fav in pairs(settings.favorites) do
    425     favlen = favlen + 1
    426     if key == favcursor then favpath = fav end
    427   end
    428   if favpath then
    429     changepath(favpath)
    430     favcursor = favcursor + 1
    431   else
    432     changepath(firstpath)
    433     favcursor = 2
    434   end
    435 end
    436 
    437 function add_keybinds()
    438   mp.add_forced_key_binding(settings.key_navdown, "navdown", navdown, "repeatable")
    439   mp.add_forced_key_binding(settings.key_navup, "navup", navup, "repeatable")
    440   mp.add_forced_key_binding(settings.key_navopen, "navopen", opendir)
    441   mp.add_forced_key_binding(settings.key_navforward, "navforward", childdir)
    442   mp.add_forced_key_binding(settings.key_navback, "navback", parentdir)
    443   mp.add_forced_key_binding(settings.key_navfavorites, "navfavorites", cyclefavorite)
    444   mp.add_forced_key_binding(settings.key_navclose, "navclose", remove_keybinds)
    445 end
    446 
    447 function remove_keybinds()
    448   timer:kill()
    449   mp.set_osd_ass(0, 0, "")
    450   if settings.dynamic_binds then
    451     mp.remove_key_binding('navdown')
    452     mp.remove_key_binding('navup')
    453     mp.remove_key_binding('navopen')
    454     mp.remove_key_binding('navforward')
    455     mp.remove_key_binding('navback')
    456     mp.remove_key_binding('navfavorites')
    457     mp.remove_key_binding('navclose')
    458   end
    459 end
    460 
    461 timer = mp.add_periodic_timer(settings.navigator_duration, remove_keybinds)
    462 timer:kill()
    463 
    464 if not settings.dynamic_binds then
    465   add_keybinds()
    466 end
    467 
    468 active=false
    469 function activate()
    470   if settings.menu_timeout then
    471     handler()
    472   else
    473     if active then
    474       remove_keybinds()
    475       active=false
    476     else
    477       handler()
    478       active=true
    479     end
    480   end
    481 end
    482 
    483 mp.add_key_binding(settings.navigator_mainkey, "navigator", activate)