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