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)